From 5172e0e611765a95f47275e7f1f05dc7a4f79b87 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Mon, 2 Mar 2026 20:58:22 +0100 Subject: [PATCH 1/3] Add test cases. Relates to #60 --- .../core/DeleteBuilderIntegrationTest.java | 109 +++++ .../EntityRepositoryBatchIntegrationTest.java | 365 ++++++++++++++ ...mplateSchemaValidationIntegrationTest.java | 124 +++++ .../core/SchemaValidatorIntegrationTest.java | 186 ++++++++ .../repository/impl/StreamSupportTest.java | 210 ++++++++ .../template/impl/DatabaseSchemaTest.java | 359 ++++++++++++++ .../template/impl/EqualitySupportTest.java | 183 +++++++ .../template/impl/MetamodelFactoryTest.java | 319 +++++++++++++ .../template/impl/PrimitiveMapperTest.java | 119 +++++ .../template/impl/RecordReflectionTest.java | 449 ++++++++++++++++++ .../template/impl/RecordValidationTest.java | 418 ++++++++++++++++ .../StormAutoConfigurationTest.kt | 88 ++++ .../RepositoryBeanFactoryPostProcessorTest.kt | 62 +++ .../st/orm/spring/RepositoryQualifierTest.kt | 89 ++++ .../spring/SpringTransactionContextTest.kt | 410 ++++++++++++++++ ...NoDirectInterpolationRuleAdditionalTest.kt | 51 ++ .../st/orm/template/ConnectionProviderTest.kt | 173 +++++++ .../template/JdbcTransactionContextTest.kt | 314 ++++++++++++ .../st/orm/template/ORMReflectionImplTest.kt | 298 ++++++++++++ .../st/orm/template/ORMTemplateFactoryTest.kt | 210 ++++++++ .../JsonORMConverterCoverageTest.kt | 251 ++++++++++ .../JsonORMConverterEdgeCaseTest.kt | 239 ++++++++++ .../JsonORMConverterProviderTest.kt | 64 +++ .../StormSerializersModuleCoverageTest.kt | 332 +++++++++++++ .../spi/mariadb/MariaDBSqlDialectTest.java | 157 ++++++ .../MSSQLServerSqlDialectTest.java | 210 ++++++++ .../st/orm/spi/mysql/MySQLSqlDialectTest.java | 176 +++++++ .../orm/spi/oracle/OracleSqlDialectTest.java | 153 ++++++ .../postgresql/PostgreSQLSqlDialectTest.java | 166 +++++++ .../StormAutoConfigurationTest.java | 41 ++ .../st/orm/test/SimpleDataSourceTest.java | 75 +++ .../test/StormExtensionAdditionalTest.java | 72 +++ .../orm/test/StormExtensionCustomUrlTest.java | 36 ++ .../test/StormExtensionMissingScriptTest.java | 25 + .../orm/test/StormExtensionNoScriptsTest.java | 35 ++ 35 files changed, 6568 insertions(+) create mode 100644 storm-core/src/test/java/st/orm/core/DeleteBuilderIntegrationTest.java create mode 100644 storm-core/src/test/java/st/orm/core/EntityRepositoryBatchIntegrationTest.java create mode 100644 storm-core/src/test/java/st/orm/core/ORMTemplateSchemaValidationIntegrationTest.java create mode 100644 storm-core/src/test/java/st/orm/core/SchemaValidatorIntegrationTest.java create mode 100644 storm-core/src/test/java/st/orm/core/repository/impl/StreamSupportTest.java create mode 100644 storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java create mode 100644 storm-core/src/test/java/st/orm/core/template/impl/EqualitySupportTest.java create mode 100644 storm-core/src/test/java/st/orm/core/template/impl/MetamodelFactoryTest.java create mode 100644 storm-core/src/test/java/st/orm/core/template/impl/PrimitiveMapperTest.java create mode 100644 storm-core/src/test/java/st/orm/core/template/impl/RecordReflectionTest.java create mode 100644 storm-core/src/test/java/st/orm/core/template/impl/RecordValidationTest.java create mode 100644 storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryBeanFactoryPostProcessorTest.kt create mode 100644 storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryQualifierTest.kt create mode 100644 storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringTransactionContextTest.kt create mode 100644 storm-kotlin/src/test/kotlin/st/orm/template/ConnectionProviderTest.kt create mode 100644 storm-kotlin/src/test/kotlin/st/orm/template/JdbcTransactionContextTest.kt create mode 100644 storm-kotlin/src/test/kotlin/st/orm/template/ORMReflectionImplTest.kt create mode 100644 storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateFactoryTest.kt create mode 100644 storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterCoverageTest.kt create mode 100644 storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterEdgeCaseTest.kt create mode 100644 storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterProviderTest.kt create mode 100644 storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/StormSerializersModuleCoverageTest.kt create mode 100644 storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBSqlDialectTest.java create mode 100644 storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/MSSQLServerSqlDialectTest.java create mode 100644 storm-mysql/src/test/java/st/orm/spi/mysql/MySQLSqlDialectTest.java create mode 100644 storm-oracle/src/test/java/st/orm/spi/oracle/OracleSqlDialectTest.java create mode 100644 storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLSqlDialectTest.java create mode 100644 storm-test/src/test/java/st/orm/test/SimpleDataSourceTest.java create mode 100644 storm-test/src/test/java/st/orm/test/StormExtensionAdditionalTest.java create mode 100644 storm-test/src/test/java/st/orm/test/StormExtensionCustomUrlTest.java create mode 100644 storm-test/src/test/java/st/orm/test/StormExtensionMissingScriptTest.java create mode 100644 storm-test/src/test/java/st/orm/test/StormExtensionNoScriptsTest.java diff --git a/storm-core/src/test/java/st/orm/core/DeleteBuilderIntegrationTest.java b/storm-core/src/test/java/st/orm/core/DeleteBuilderIntegrationTest.java new file mode 100644 index 000000000..dae4b7199 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/DeleteBuilderIntegrationTest.java @@ -0,0 +1,109 @@ +package st.orm.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static st.orm.Operator.EQUALS; + +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import st.orm.PersistenceException; +import st.orm.core.model.City; +import st.orm.core.model.City_; +import st.orm.core.template.ORMTemplate; +import st.orm.core.template.TemplateString; + +/** + * Integration tests for DeleteBuilder covering distinct, offset, limit, forShare, forUpdate, + * forLock, subquery, and getResultStream restrictions. + */ +@SuppressWarnings("ALL") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = IntegrationConfig.class) +@DataJpaTest(showSql = false) +public class DeleteBuilderIntegrationTest { + + @Autowired + private DataSource dataSource; + + @Test + public void testDeleteDistinctThrows() { + var orm = ORMTemplate.of(dataSource); + assertThrows(PersistenceException.class, + () -> orm.deleteFrom(City.class).distinct()); + } + + @Test + public void testDeleteOffsetThrows() { + var orm = ORMTemplate.of(dataSource); + assertThrows(PersistenceException.class, + () -> orm.deleteFrom(City.class).offset(1)); + } + + @Test + public void testDeleteLimitThrows() { + var orm = ORMTemplate.of(dataSource); + assertThrows(PersistenceException.class, + () -> orm.deleteFrom(City.class).limit(1)); + } + + @Test + public void testDeleteForShareThrows() { + var orm = ORMTemplate.of(dataSource); + assertThrows(PersistenceException.class, + () -> orm.deleteFrom(City.class).forShare()); + } + + @Test + public void testDeleteForUpdateThrows() { + var orm = ORMTemplate.of(dataSource); + assertThrows(PersistenceException.class, + () -> orm.deleteFrom(City.class).forUpdate()); + } + + @Test + public void testDeleteForLockThrows() { + var orm = ORMTemplate.of(dataSource); + assertThrows(PersistenceException.class, + () -> orm.deleteFrom(City.class).forLock(TemplateString.of("FOR LOCK"))); + } + + @Test + public void testDeleteGetResultStreamThrows() { + var orm = ORMTemplate.of(dataSource); + assertThrows(PersistenceException.class, + () -> orm.deleteFrom(City.class).unsafe().getResultStream()); + } + + @Test + public void testDeleteWithWhereClause() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + // Insert a city we can delete. + var insertedId = cities.insertAndFetchId(City.builder().name("DeleteMe").build()); + long countBefore = cities.count(); + + orm.deleteFrom(City.class) + .where(City_.id, EQUALS, insertedId) + .executeUpdate(); + + assertEquals(countBefore - 1, cities.count()); + } + + @Test + public void testDeleteWithUnsafe() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + // Insert a standalone city. + cities.insertAndFetchId(City.builder().name("UnsafeDelete").build()); + + // Unsafe delete without WHERE should attempt to delete all. + // This will fail due to FK constraints on existing cities, which is expected. + assertThrows(PersistenceException.class, + () -> orm.deleteFrom(City.class).unsafe().executeUpdate()); + } +} diff --git a/storm-core/src/test/java/st/orm/core/EntityRepositoryBatchIntegrationTest.java b/storm-core/src/test/java/st/orm/core/EntityRepositoryBatchIntegrationTest.java new file mode 100644 index 000000000..1c0bbc82c --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/EntityRepositoryBatchIntegrationTest.java @@ -0,0 +1,365 @@ +package st.orm.core; + +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.assertTrue; +import static st.orm.Operator.EQUALS; +import static st.orm.Operator.IN; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import st.orm.Ref; +import st.orm.core.model.City; +import st.orm.core.model.City_; +import st.orm.core.template.ORMTemplate; +import st.orm.core.template.TemplateString; + +/** + * Integration tests for EntityRepository batch operations, update operations, + * and various query builder patterns. + */ +@SuppressWarnings("ALL") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = IntegrationConfig.class) +@DataJpaTest(showSql = false) +public class EntityRepositoryBatchIntegrationTest { + + @Autowired + private DataSource dataSource; + + // The seed data contains exactly 6 cities: + // id=1: "Sun Paririe", id=2: "Madison", id=3: "McFarland", + // id=4: "Windsor", id=5: "Monona", id=6: "Waunakee" + private static final int SEED_CITY_COUNT = 6; + + // ---- Batch insert ---- + + @Test + public void testBatchInsertIncreasesCityCount() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + long countBefore = cities.count(); + + cities.insert(List.of( + City.builder().name("BatchCity1").build(), + City.builder().name("BatchCity2").build(), + City.builder().name("BatchCity3").build() + )); + assertEquals(countBefore + 3, cities.count()); + } + + @Test + public void testBatchInsertAndFetchIdsReturnsValidIds() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + List ids = cities.insertAndFetchIds(List.of( + City.builder().name("FetchId1").build(), + City.builder().name("FetchId2").build() + )); + assertEquals(2, ids.size()); + // Verify each returned ID corresponds to the correct inserted city. + assertEquals("FetchId1", cities.getById(ids.get(0)).name()); + assertEquals("FetchId2", cities.getById(ids.get(1)).name()); + } + + // ---- Update ---- + + @Test + public void testUpdateEntityPersistsNewName() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + var insertedId = cities.insertAndFetchId(City.builder().name("BeforeUpdate").build()); + cities.update(City.builder().id(insertedId).name("AfterUpdate").build()); + + assertEquals("AfterUpdate", cities.getById(insertedId).name()); + } + + @Test + public void testBatchUpdatePersistsAllChanges() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + var id1 = cities.insertAndFetchId(City.builder().name("BatchUpdate1").build()); + var id2 = cities.insertAndFetchId(City.builder().name("BatchUpdate2").build()); + + cities.update(List.of( + City.builder().id(id1).name("Updated1").build(), + City.builder().id(id2).name("Updated2").build() + )); + + assertEquals("Updated1", cities.getById(id1).name()); + assertEquals("Updated2", cities.getById(id2).name()); + } + + // ---- Delete ---- + + @Test + public void testDeleteEntityRemovesIt() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + var insertedId = cities.insertAndFetchId(City.builder().name("ToDelete").build()); + long countBefore = cities.count(); + + cities.delete(City.builder().id(insertedId).name("ToDelete").build()); + assertEquals(countBefore - 1, cities.count()); + // Verify the deleted city is actually gone. + assertFalse(cities.findById(insertedId).isPresent()); + } + + @Test + public void testDeleteByRefRemovesEntity() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + var insertedId = cities.insertAndFetchId(City.builder().name("RefDelete").build()); + cities.deleteByRef(Ref.of(City.class, insertedId)); + + assertFalse(cities.findById(insertedId).isPresent()); + } + + @Test + public void testBatchDeleteRemovesAllSpecified() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + var id1 = cities.insertAndFetchId(City.builder().name("BatchDel1").build()); + var id2 = cities.insertAndFetchId(City.builder().name("BatchDel2").build()); + long countBefore = cities.count(); + + cities.delete(List.of( + City.builder().id(id1).name("BatchDel1").build(), + City.builder().id(id2).name("BatchDel2").build() + )); + assertEquals(countBefore - 2, cities.count()); + assertFalse(cities.findById(id1).isPresent()); + assertFalse(cities.findById(id2).isPresent()); + } + + @Test + public void testBatchDeleteByRefRemovesAllSpecified() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + var id1 = cities.insertAndFetchId(City.builder().name("RefBatchDel1").build()); + var id2 = cities.insertAndFetchId(City.builder().name("RefBatchDel2").build()); + + cities.deleteByRef(List.of( + Ref.of(City.class, id1), + Ref.of(City.class, id2) + )); + assertFalse(cities.findById(id1).isPresent()); + assertFalse(cities.findById(id2).isPresent()); + } + + // ---- Query builder: WHERE ---- + + @Test + public void testWhereEqualsReturnsOnlyMatchingCity() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + var result = cities.select() + .where(City_.name, EQUALS, "Madison") + .getResultList(); + assertEquals(1, result.size()); + assertEquals("Madison", result.get(0).name()); + } + + @Test + public void testWhereInReturnsOnlyRequestedCities() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + var result = cities.select() + .where(City_.name, IN, List.of("Madison", "Monona")) + .getResultList(); + assertEquals(2, result.size()); + Set names = result.stream().map(City::name).collect(Collectors.toSet()); + assertEquals(Set.of("Madison", "Monona"), names); + } + + // ---- Count and existence ---- + + @Test + public void testCountMatchesSeedData() { + var orm = ORMTemplate.of(dataSource); + assertEquals(SEED_CITY_COUNT, orm.entity(City.class).count()); + } + + @Test + public void testExistsByIdReturnsTrueForSeedCity() { + var orm = ORMTemplate.of(dataSource); + // City id=2 is "Madison" from seed data. + assertTrue(orm.entity(City.class).existsById(2)); + } + + @Test + public void testExistsByIdReturnsFalseForAbsentId() { + var orm = ORMTemplate.of(dataSource); + assertFalse(orm.entity(City.class).existsById(99999)); + } + + // ---- Get/Find by ID and Ref ---- + + @Test + public void testGetByIdReturnsCorrectCity() { + var orm = ORMTemplate.of(dataSource); + City city = orm.entity(City.class).getById(2); + assertEquals(2, city.id()); + assertEquals("Madison", city.name()); + } + + @Test + public void testFindByIdReturnsPresentForExistingCity() { + var orm = ORMTemplate.of(dataSource); + Optional city = orm.entity(City.class).findById(2); + assertTrue(city.isPresent()); + assertEquals("Madison", city.get().name()); + } + + @Test + public void testFindByIdReturnsEmptyForAbsentId() { + var orm = ORMTemplate.of(dataSource); + assertFalse(orm.entity(City.class).findById(99999).isPresent()); + } + + @Test + public void testGetByRefReturnsCorrectCity() { + var orm = ORMTemplate.of(dataSource); + City city = orm.entity(City.class).getByRef(Ref.of(City.class, 3)); + assertEquals(3, city.id()); + assertEquals("McFarland", city.name()); + } + + // ---- InsertAndFetch ---- + + @Test + public void testInsertAndFetchReturnsCompleteEntity() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + City fetched = cities.insertAndFetch(City.builder().name("InsertAndFetch").build()); + assertNotNull(fetched.id()); + assertTrue(fetched.id() > SEED_CITY_COUNT); + assertEquals("InsertAndFetch", fetched.name()); + } + + // ---- UpdateAndFetch ---- + + @Test + public void testUpdateAndFetchReturnsUpdatedEntity() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + + var insertedId = cities.insertAndFetchId(City.builder().name("BeforeUAF").build()); + City updated = cities.updateAndFetch( + City.builder().id(insertedId).name("AfterUAF").build()); + assertEquals("AfterUAF", updated.name()); + assertEquals(insertedId, updated.id()); + } + + // ---- Query stream ---- + + @Test + public void testStreamCountMatchesRepositoryCount() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + long streamCount = cities.select().getResultStream().count(); + assertEquals(cities.count(), streamCount); + } + + // ---- OrderBy ---- + + @Test + public void testOrderByAscendingReturnsSortedResults() { + var orm = ORMTemplate.of(dataSource); + var result = orm.entity(City.class).select() + .orderBy(City_.name) + .getResultList(); + assertEquals(SEED_CITY_COUNT, result.size()); + for (int i = 1; i < result.size(); i++) { + assertTrue(result.get(i - 1).name().compareTo(result.get(i).name()) <= 0, + "Expected '%s' <= '%s'".formatted(result.get(i - 1).name(), result.get(i).name())); + } + } + + @Test + public void testOrderByDescendingReturnsSortedResults() { + var orm = ORMTemplate.of(dataSource); + var result = orm.entity(City.class).select() + .orderByDescending(City_.name) + .getResultList(); + assertEquals(SEED_CITY_COUNT, result.size()); + for (int i = 1; i < result.size(); i++) { + assertTrue(result.get(i - 1).name().compareTo(result.get(i).name()) >= 0, + "Expected '%s' >= '%s'".formatted(result.get(i - 1).name(), result.get(i).name())); + } + } + + // ---- Distinct ---- + + @Test + public void testDistinctOnNameProjectionCollapsesDuplicates() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + // Insert two cities with the same name to create duplicate names. + cities.insert(City.builder().name("DuplicateName").build()); + cities.insert(City.builder().name("DuplicateName").build()); + + // Select all names (including duplicates). + List allNames = cities.select(String.class, TemplateString.of("name")) + .getResultList(); + // Select distinct names only. + List distinctNames = cities.select(String.class, TemplateString.of("name")) + .distinct() + .getResultList(); + + // There should be 2 more total names than distinct names (the 2 duplicate "DuplicateName" entries + // collapse to 1 in the distinct query). + assertEquals(allNames.size() - 1, distinctNames.size(), + "Distinct should collapse the duplicate 'DuplicateName' entries into one"); + // Verify "DuplicateName" appears exactly once in distinct results. + assertEquals(1, distinctNames.stream().filter("DuplicateName"::equals).count()); + } + + // ---- Limit and Offset ---- + + @Test + public void testLimitRestrictsResultCount() { + var orm = ORMTemplate.of(dataSource); + var result = orm.entity(City.class).select() + .limit(2) + .getResultList(); + assertEquals(2, result.size()); + } + + @Test + public void testOffsetSkipsRows() { + var orm = ORMTemplate.of(dataSource); + var cities = orm.entity(City.class); + var allCities = cities.select().orderBy(City_.id).getResultList(); + + var offsetResult = cities.select() + .orderBy(City_.id) + .offset(2) + .limit(2) + .getResultList(); + assertEquals(2, offsetResult.size()); + // The offset results should match the 3rd and 4th cities from the full list. + assertEquals(allCities.get(2).id(), offsetResult.get(0).id()); + assertEquals(allCities.get(3).id(), offsetResult.get(1).id()); + } +} diff --git a/storm-core/src/test/java/st/orm/core/ORMTemplateSchemaValidationIntegrationTest.java b/storm-core/src/test/java/st/orm/core/ORMTemplateSchemaValidationIntegrationTest.java new file mode 100644 index 000000000..47ba97d1b --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/ORMTemplateSchemaValidationIntegrationTest.java @@ -0,0 +1,124 @@ +package st.orm.core; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import st.orm.core.model.City; +import st.orm.core.model.Owner; +import st.orm.core.model.Pet; +import st.orm.core.template.ORMTemplate; + +/** + * Integration tests for schema validation methods of ORMTemplate, entityCallbacks, config, and + * entity/projection repository access. + */ +@SuppressWarnings("ALL") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = IntegrationConfig.class) +@DataJpaTest(showSql = false) +public class ORMTemplateSchemaValidationIntegrationTest { + + @Autowired + private DataSource dataSource; + + @Test + public void testValidateSchemaReturnsResults() { + var orm = ORMTemplate.of(dataSource); + List results = orm.validateSchema(); + assertNotNull(results); + } + + @Test + public void testValidateSchemaWithSpecificTypes() { + var orm = ORMTemplate.of(dataSource); + List results = orm.validateSchema(List.of(City.class)); + assertNotNull(results); + assertTrue(results.isEmpty(), "Expected no validation errors for City, got: " + results); + } + + @Test + public void testValidateSchemaOrThrow() { + var orm = ORMTemplate.of(dataSource); + assertDoesNotThrow(() -> orm.validateSchemaOrThrow(List.of(City.class))); + } + + @Test + public void testEntityCallbacksDefaultsToEmpty() { + var orm = ORMTemplate.of(dataSource); + assertNotNull(orm.entityCallbacks()); + assertTrue(orm.entityCallbacks().isEmpty()); + } + + @Test + public void testWithEntityCallbacksPreservesQueryBehavior() { + var orm = ORMTemplate.of(dataSource); + var ormWithCallbacks = orm.withEntityCallbacks(List.of()); + // The new template should still be functional and return the same data. + assertEquals(orm.entity(City.class).count(), ormWithCallbacks.entity(City.class).count()); + } + + @Test + public void testConfigFallsBackToSystemProperties() { + var orm = ORMTemplate.of(dataSource); + var config = orm.config(); + // Default config reads from system properties; requesting an unset key returns null. + assertNull(config.getProperty("storm.nonexistent.test.key")); + // getProperty with default should return the default for unset keys. + assertEquals("fallback", config.getProperty("storm.nonexistent.test.key", "fallback")); + } + + @Test + public void testEntityRepository() { + var orm = ORMTemplate.of(dataSource); + var cityRepo = orm.entity(City.class); + assertNotNull(cityRepo); + assertTrue(cityRepo.count() > 0); + } + + @Test + public void testEntityRepositoryCaching() { + var orm = ORMTemplate.of(dataSource); + var cityRepo1 = orm.entity(City.class); + var cityRepo2 = orm.entity(City.class); + // Same instance should be returned (cached). + assertTrue(cityRepo1 == cityRepo2); + } + + @Test + public void testMultipleEntityRepositories() { + var orm = ORMTemplate.of(dataSource); + var cityRepo = orm.entity(City.class); + var ownerRepo = orm.entity(Owner.class); + assertNotNull(cityRepo); + assertNotNull(ownerRepo); + assertTrue(cityRepo.count() > 0); + assertTrue(ownerRepo.count() > 0); + } + + @Test + public void testValidateSchemaWithMultipleTypesReturnsNoErrors() { + var orm = ORMTemplate.of(dataSource); + // Non-strict validateSchema should report no errors for valid, existing tables. + List results = orm.validateSchema(List.of(City.class, Owner.class, Pet.class)); + assertTrue(results.isEmpty(), + "Expected no validation errors for valid types, got: " + results); + } + + @Test + public void testValidateSchemaOrThrowWithMultipleTypes() { + var orm = ORMTemplate.of(dataSource); + // These should all be valid given the test data setup. + assertDoesNotThrow(() -> orm.validateSchemaOrThrow(List.of(City.class))); + } +} diff --git a/storm-core/src/test/java/st/orm/core/SchemaValidatorIntegrationTest.java b/storm-core/src/test/java/st/orm/core/SchemaValidatorIntegrationTest.java new file mode 100644 index 000000000..0785245bc --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/SchemaValidatorIntegrationTest.java @@ -0,0 +1,186 @@ +package st.orm.core; + +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; + +import java.util.List; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import st.orm.DbTable; +import st.orm.Entity; +import st.orm.GenerationStrategy; +import st.orm.PK; +import st.orm.core.model.City; +import st.orm.core.model.Owner; +import st.orm.core.model.Pet; +import st.orm.core.template.impl.SchemaValidationError; +import st.orm.core.template.impl.SchemaValidationException; +import st.orm.core.template.impl.SchemaValidator; + +/** + * Integration tests for {@link SchemaValidator} covering table existence, column validation, + * primary key matching, unique key validation, foreign key validation, and error reporting. + */ +@SuppressWarnings("ALL") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = IntegrationConfig.class) +@DataJpaTest(showSql = false) +public class SchemaValidatorIntegrationTest { + + @Autowired + private DataSource dataSource; + + // ---- Valid types ---- + + @Test + public void testValidateValidTypes() { + var validator = SchemaValidator.of(dataSource); + List errors = validator.validate(List.of(City.class)); + assertNotNull(errors); + // Filter out warnings (e.g., NULLABILITY_MISMATCH) to check for hard errors only. + List hardErrors = errors.stream() + .filter(e -> !e.kind().warning()) + .toList(); + assertTrue(hardErrors.isEmpty(), "Expected no hard errors for City, got: " + hardErrors); + } + + @Test + public void testValidateMultipleValidTypesHasNoHardErrors() { + var validator = SchemaValidator.of(dataSource); + List errors = validator.validate(List.of(City.class, Owner.class, Pet.class)); + // Filter out warnings like NULLABILITY_MISMATCH; only check for structural errors. + List hardErrors = errors.stream() + .filter(e -> !e.kind().warning()) + .toList(); + assertTrue(hardErrors.isEmpty(), + "Expected no hard errors for valid types, got: " + hardErrors); + } + + // ---- Missing table ---- + + @DbTable("nonexistent_table") + public record NonexistentTableEntity( + @PK Integer id, + String name + ) implements Entity {} + + @Test + public void testValidateDetectsMissingTable() { + var validator = SchemaValidator.of(dataSource); + List errors = validator.validate(List.of(NonexistentTableEntity.class)); + assertFalse(errors.isEmpty()); + assertTrue(errors.stream().anyMatch( + error -> error.kind() == SchemaValidationError.ErrorKind.TABLE_NOT_FOUND)); + } + + // ---- Missing column ---- + + @DbTable("city") + public record CityWithExtraColumn( + @PK Integer id, + String name, + String nonexistentColumn + ) implements Entity {} + + @Test + public void testValidateDetectsMissingColumn() { + var validator = SchemaValidator.of(dataSource); + List errors = validator.validate(List.of(CityWithExtraColumn.class)); + assertFalse(errors.isEmpty()); + assertTrue(errors.stream().anyMatch( + error -> error.kind() == SchemaValidationError.ErrorKind.COLUMN_NOT_FOUND)); + } + + // ---- Type incompatible ---- + + @DbTable("city") + public record CityWithIncompatibleType( + @PK String id, + String name + ) implements Entity {} + + @Test + public void testValidateDetectsTypeIncompatibility() { + var validator = SchemaValidator.of(dataSource); + // City.id is an INTEGER in the database, but mapped as String in the entity. + List errors = validator.validate(List.of(CityWithIncompatibleType.class)); + List hardErrors = errors.stream() + .filter(e -> !e.kind().warning()) + .toList(); + assertFalse(hardErrors.isEmpty(), + "Expected hard errors for String-mapped INTEGER column, got: " + errors); + } + + // ---- validateOrThrow throws for invalid type ---- + + @Test + public void testValidateOrThrowThrowsForInvalidType() { + var validator = SchemaValidator.of(dataSource); + assertThrows(SchemaValidationException.class, + () -> validator.validateOrThrow(List.of(NonexistentTableEntity.class))); + } + + // ---- validateAndReport ---- + + @Test + public void testValidateAndReportNonStrictIgnoresNullabilityWarnings() { + var validator = SchemaValidator.of(dataSource); + List errors = validator.validateAndReport(List.of(City.class), false); + // Non-strict mode should exclude NULLABILITY_MISMATCH warnings from the error list. + assertTrue(errors.isEmpty(), "Expected no errors in non-strict mode for City, got: " + errors); + } + + @Test + public void testValidateAndReportStrictIncludesNullabilityWarnings() { + var validator = SchemaValidator.of(dataSource); + List errors = validator.validateAndReport(List.of(City.class), true); + // Strict mode should treat NULLABILITY_MISMATCH as an error. + assertFalse(errors.isEmpty(), + "Expected strict mode to report nullability warnings as errors for City"); + assertTrue(errors.stream().anyMatch(e -> e.contains("NULLABILITY_MISMATCH")), + "Expected NULLABILITY_MISMATCH in strict error messages, got: " + errors); + } + + @Test + public void testValidateAndReportForMissingTableIncludesTableName() { + var validator = SchemaValidator.of(dataSource); + List errors = validator.validateAndReport(List.of(NonexistentTableEntity.class), true); + assertFalse(errors.isEmpty()); + assertTrue(errors.stream().anyMatch(e -> e.contains("nonexistent_table")), + "Error message should reference the missing table name, got: " + errors); + } + + // ---- Empty type list ---- + + @Test + public void testValidateEmptyTypeList() { + var validator = SchemaValidator.of(dataSource); + List errors = validator.validate(List.of()); + assertNotNull(errors); + assertTrue(errors.isEmpty()); + } + + // ---- Sequence validation (testing with a type that references a nonexistent sequence) ---- + + @DbTable("city") + public record CityWithSequence( + @PK(generation = GenerationStrategy.SEQUENCE, sequence = "nonexistent_seq") Integer id, + String name + ) implements Entity {} + + @Test + public void testValidateDetectsMissingSequence() { + var validator = SchemaValidator.of(dataSource); + List errors = validator.validate(List.of(CityWithSequence.class)); + // Should detect that the sequence does not exist. + assertTrue(errors.stream().anyMatch( + error -> error.kind() == SchemaValidationError.ErrorKind.SEQUENCE_NOT_FOUND)); + } +} diff --git a/storm-core/src/test/java/st/orm/core/repository/impl/StreamSupportTest.java b/storm-core/src/test/java/st/orm/core/repository/impl/StreamSupportTest.java new file mode 100644 index 000000000..036f5a94b --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/repository/impl/StreamSupportTest.java @@ -0,0 +1,210 @@ +package st.orm.core.repository.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import st.orm.core.repository.impl.StreamSupport.Partition; + +/** + * Tests for {@link StreamSupport} covering chunked and partitioned stream operations. + */ +class StreamSupportTest { + + // ---- chunked tests ---- + + @Test + void testChunkedBasic() { + var chunks = StreamSupport.chunked(Stream.of(1, 2, 3, 4, 5), 2).toList(); + assertEquals(3, chunks.size()); + assertEquals(List.of(1, 2), chunks.get(0)); + assertEquals(List.of(3, 4), chunks.get(1)); + assertEquals(List.of(5), chunks.get(2)); + } + + @Test + void testChunkedExactMultiple() { + var chunks = StreamSupport.chunked(Stream.of(1, 2, 3, 4), 2).toList(); + assertEquals(2, chunks.size()); + assertEquals(List.of(1, 2), chunks.get(0)); + assertEquals(List.of(3, 4), chunks.get(1)); + } + + @Test + void testChunkedSingleElement() { + var chunks = StreamSupport.chunked(Stream.of(1), 3).toList(); + assertEquals(1, chunks.size()); + assertEquals(List.of(1), chunks.get(0)); + } + + @Test + void testChunkedEmptyStream() { + var chunks = StreamSupport.chunked(Stream.empty(), 5).toList(); + assertEquals(0, chunks.size()); + } + + @Test + void testChunkedMaxValue() { + // Integer.MAX_VALUE should return a single slice. + var chunks = StreamSupport.chunked(Stream.of(1, 2, 3), Integer.MAX_VALUE).toList(); + assertEquals(1, chunks.size()); + assertEquals(List.of(1, 2, 3), chunks.get(0)); + } + + @Test + void testChunkedInvalidSize() { + assertThrows(IllegalArgumentException.class, () -> StreamSupport.chunked(Stream.of(1), 0)); + assertThrows(IllegalArgumentException.class, () -> StreamSupport.chunked(Stream.of(1), -1)); + } + + @Test + void testChunkedStreamCloses() { + var closed = new boolean[]{false}; + var stream = Stream.of(1, 2, 3).onClose(() -> closed[0] = true); + var result = StreamSupport.chunked(stream, 2); + result.close(); + assertTrue(closed[0]); + } + + // ---- partitioned tests (without overflow) ---- + + @Test + void testPartitionedBasic() { + var partitions = StreamSupport.partitioned( + Stream.of("a1", "b1", "a2", "b2", "a3"), + 2, + s -> s.substring(0, 1) + ).toList(); + + // "a" should have chunks: [a1, a2] and [a3] + // "b" should have chunk: [b1, b2] + long aChunks = partitions.stream().filter(p -> p.key().equals("a")).count(); + long bChunks = partitions.stream().filter(p -> p.key().equals("b")).count(); + assertEquals(2, aChunks); // a has 3 elements, split into chunks of 2 + assertEquals(1, bChunks); // b has 2 elements, one full chunk + } + + @Test + void testPartitionedSingleKey() { + var partitions = StreamSupport.partitioned( + Stream.of(1, 2, 3, 4, 5), + 3, + x -> "all" + ).toList(); + + assertEquals(2, partitions.size()); + assertEquals(List.of(1, 2, 3), partitions.get(0).chunk()); + assertEquals(List.of(4, 5), partitions.get(1).chunk()); + } + + @Test + void testPartitionedEmptyStream() { + var partitions = StreamSupport.partitioned( + Stream.empty(), + 5, + s -> s + ).toList(); + + assertTrue(partitions.isEmpty()); + } + + @Test + void testPartitionedMaxValueSize() { + var partitions = StreamSupport.partitioned( + Stream.of(1, 2, 3), + Integer.MAX_VALUE, + x -> "key" + ).toList(); + + assertEquals(1, partitions.size()); + assertEquals(List.of(1, 2, 3), partitions.get(0).chunk()); + } + + @Test + void testPartitionedInvalidSize() { + assertThrows(IllegalArgumentException.class, + () -> StreamSupport.partitioned(Stream.of(1), 0, x -> "key")); + } + + // ---- partitioned tests (with overflow) ---- + + @Test + void testPartitionedWithOverflow() { + // maxPartitions=2 means 1 normal key + 1 overflow slot. + var partitions = StreamSupport.partitioned( + Stream.of("a1", "b1", "c1", "a2"), + 10, + s -> s.substring(0, 1), + 2, + "overflow" + ).toList(); + + // First key "a" takes the single normal slot. "b" and "c" go to overflow. + long normalPartitions = partitions.stream().filter(p -> !p.key().equals("overflow")).count(); + long overflowPartitions = partitions.stream().filter(p -> p.key().equals("overflow")).count(); + assertTrue(normalPartitions >= 1); + assertTrue(overflowPartitions >= 1); + } + + @Test + void testPartitionedWithOverflowAllToOverflow() { + // maxPartitions=1 means all elements go to overflow. + var partitions = StreamSupport.partitioned( + Stream.of("a1", "b1", "c1"), + 10, + s -> s.substring(0, 1), + 1, + "overflow" + ).toList(); + + assertEquals(1, partitions.size()); + assertEquals("overflow", partitions.get(0).key()); + assertEquals(3, partitions.get(0).chunk().size()); + } + + @Test + void testPartitionedInvalidMaxPartitions() { + assertThrows(IllegalArgumentException.class, + () -> StreamSupport.partitioned(Stream.of(1), 1, x -> "key", 0, "overflow")); + } + + @Test + void testPartitionedNullOverflowKeyWithLimitedPartitions() { + assertThrows(NullPointerException.class, + () -> StreamSupport.partitioned(Stream.of(1), 1, x -> "key", 2, null)); + } + + @Test + void testPartitionedStreamCloses() { + var closed = new boolean[]{false}; + var stream = Stream.of("a1", "b1").onClose(() -> closed[0] = true); + var result = StreamSupport.partitioned(stream, 10, s -> s.substring(0, 1)); + result.close(); + assertTrue(closed[0]); + } + + @Test + void testPartitionRecord() { + Partition partition = new Partition<>("key", List.of(1, 2, 3)); + assertEquals("key", partition.key()); + assertEquals(List.of(1, 2, 3), partition.chunk()); + } + + @Test + void testPartitionedWithOverflowKeyInInput() { + // When overflow key appears in input, it should be treated as overflow key. + var partitions = StreamSupport.partitioned( + Stream.of("a1", "overflow1", "b1"), + 10, + s -> s.startsWith("overflow") ? "overflow" : s.substring(0, 1), + 3, + "overflow" + ).toList(); + + boolean hasOverflow = partitions.stream().anyMatch(p -> p.key().equals("overflow")); + assertTrue(hasOverflow); + } +} diff --git a/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java b/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java new file mode 100644 index 000000000..9dac5093a --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java @@ -0,0 +1,359 @@ +package st.orm.core.template.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import st.orm.core.template.SqlDialect.ConstraintDiscoveryStrategy; +import st.orm.core.template.SqlDialect.SequenceDiscoveryStrategy; +import st.orm.core.template.impl.DatabaseSchema.DbColumn; +import st.orm.core.template.impl.DatabaseSchema.DbForeignKey; +import st.orm.core.template.impl.DatabaseSchema.DbPrimaryKey; +import st.orm.core.template.impl.DatabaseSchema.DbUniqueKey; + +/** + * Tests for {@link DatabaseSchema} to cover schema reading, table/column/PK/UK/FK/sequence + * lookups with various discovery strategies. + */ +class DatabaseSchemaTest { + + private static final AtomicInteger DB_COUNTER = new AtomicInteger(); + + private String jdbcUrl; + + @BeforeEach + void setUp() { + jdbcUrl = "jdbc:h2:mem:db_schema_test_" + DB_COUNTER.incrementAndGet() + ";DB_CLOSE_DELAY=-1"; + } + + private Connection getConnection() throws SQLException { + return DriverManager.getConnection(jdbcUrl); + } + + private void execute(String sql) throws SQLException { + try (Connection connection = getConnection(); + var statement = connection.createStatement()) { + statement.execute(sql); + } + } + + // ---- Basic read() tests ---- + + @Test + void testReadEmptySchema() throws SQLException { + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + assertFalse(schema.tableExists("nonexistent")); + } + } + + @Test + void testTableExists() throws SQLException { + execute("CREATE TABLE test_table (id INTEGER AUTO_INCREMENT, name VARCHAR(255), PRIMARY KEY (id))"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + assertTrue(schema.tableExists("test_table")); + assertTrue(schema.tableExists("TEST_TABLE")); // case-insensitive + assertFalse(schema.tableExists("no_such_table")); + } + } + + @Test + void testGetColumn() throws SQLException { + execute("CREATE TABLE col_test (id INTEGER AUTO_INCREMENT, name VARCHAR(100) NOT NULL, description VARCHAR(500), PRIMARY KEY (id))"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + + assertTrue(schema.getColumn("col_test", "id").isPresent()); + assertTrue(schema.getColumn("col_test", "name").isPresent()); + assertTrue(schema.getColumn("col_test", "description").isPresent()); + assertTrue(schema.getColumn("col_test", "NAME").isPresent()); // case-insensitive + + assertFalse(schema.getColumn("col_test", "nonexistent").isPresent()); + assertFalse(schema.getColumn("nonexistent_table", "id").isPresent()); + } + } + + @Test + void testGetColumnProperties() throws SQLException { + execute("CREATE TABLE prop_test (id INTEGER AUTO_INCREMENT, name VARCHAR(100) NOT NULL, description VARCHAR(500), PRIMARY KEY (id))"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + + DbColumn idColumn = schema.getColumn("prop_test", "id").orElseThrow(); + assertTrue(idColumn.autoIncrement()); + + DbColumn nameColumn = schema.getColumn("prop_test", "name").orElseThrow(); + assertFalse(nameColumn.nullable()); + assertFalse(nameColumn.autoIncrement()); + + DbColumn descColumn = schema.getColumn("prop_test", "description").orElseThrow(); + assertTrue(descColumn.nullable()); + } + } + + @Test + void testGetPrimaryKeys() throws SQLException { + execute("CREATE TABLE pk_test (id INTEGER, name VARCHAR(255), PRIMARY KEY (id))"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + + List primaryKeys = schema.getPrimaryKeys("pk_test"); + assertFalse(primaryKeys.isEmpty()); + assertEquals(1, primaryKeys.size()); + assertEquals("ID", primaryKeys.getFirst().columnName()); + } + } + + @Test + void testGetCompoundPrimaryKeys() throws SQLException { + execute("CREATE TABLE cpk_test (col_a INTEGER, col_b INTEGER, name VARCHAR(255), PRIMARY KEY (col_a, col_b))"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + + List primaryKeys = schema.getPrimaryKeys("cpk_test"); + assertEquals(2, primaryKeys.size()); + } + } + + @Test + void testGetPrimaryKeysForNonExistentTable() throws SQLException { + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + List primaryKeys = schema.getPrimaryKeys("nonexistent"); + assertTrue(primaryKeys.isEmpty()); + } + } + + @Test + void testGetUniqueKeys() throws SQLException { + execute("CREATE TABLE uk_test (id INTEGER AUTO_INCREMENT, email VARCHAR(255) NOT NULL, PRIMARY KEY (id), UNIQUE (email))"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + + List uniqueKeys = schema.getUniqueKeys("uk_test"); + assertFalse(uniqueKeys.isEmpty()); + } + } + + @Test + void testGetUniqueKeysForNonExistentTable() throws SQLException { + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + List uniqueKeys = schema.getUniqueKeys("nonexistent"); + assertTrue(uniqueKeys.isEmpty()); + } + } + + @Test + void testGetForeignKeys() throws SQLException { + execute("CREATE TABLE fk_parent (id INTEGER AUTO_INCREMENT, name VARCHAR(255), PRIMARY KEY (id))"); + execute("CREATE TABLE fk_child (id INTEGER AUTO_INCREMENT, parent_id INTEGER, PRIMARY KEY (id), FOREIGN KEY (parent_id) REFERENCES fk_parent(id))"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + + List foreignKeys = schema.getForeignKeys("fk_child"); + assertFalse(foreignKeys.isEmpty()); + assertEquals(1, foreignKeys.size()); + DbForeignKey foreignKey = foreignKeys.getFirst(); + assertEquals("FK_PARENT", foreignKey.pkTableName()); + } + } + + @Test + void testGetForeignKeysForNonExistentTable() throws SQLException { + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + List foreignKeys = schema.getForeignKeys("nonexistent"); + assertTrue(foreignKeys.isEmpty()); + } + } + + @Test + void testSequenceExists() throws SQLException { + execute("CREATE SEQUENCE test_seq START WITH 1"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + + assertTrue(schema.sequenceExists("test_seq")); + assertTrue(schema.sequenceExists("TEST_SEQ")); // case-insensitive + assertFalse(schema.sequenceExists("nonexistent_seq")); + } + } + + // ---- Discovery strategy tests ---- + + @Test + void testReadWithJdbcMetadataStrategy() throws SQLException { + execute("CREATE TABLE jdbc_test (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, PRIMARY KEY (id))"); + execute("CREATE TABLE jdbc_ref (id INTEGER AUTO_INCREMENT, test_id INTEGER, PRIMARY KEY (id), FOREIGN KEY (test_id) REFERENCES jdbc_test(id))"); + execute("CREATE UNIQUE INDEX idx_name ON jdbc_test(name)"); + + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection, + connection.getCatalog(), connection.getSchema(), + SequenceDiscoveryStrategy.INFORMATION_SCHEMA, + ConstraintDiscoveryStrategy.JDBC_METADATA); + + assertTrue(schema.tableExists("jdbc_test")); + assertTrue(schema.getColumn("jdbc_test", "id").isPresent()); + + List primaryKeys = schema.getPrimaryKeys("jdbc_test"); + assertFalse(primaryKeys.isEmpty()); + + List uniqueKeys = schema.getUniqueKeys("jdbc_test"); + assertFalse(uniqueKeys.isEmpty()); + + List foreignKeys = schema.getForeignKeys("jdbc_ref"); + assertFalse(foreignKeys.isEmpty()); + } + } + + @Test + void testReadWithInformationSchemaStrategy() throws SQLException { + execute("CREATE TABLE info_test (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, PRIMARY KEY (id))"); + execute("CREATE TABLE info_ref (id INTEGER AUTO_INCREMENT, test_id INTEGER, PRIMARY KEY (id), FOREIGN KEY (test_id) REFERENCES info_test(id))"); + + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection, + connection.getCatalog(), connection.getSchema(), + SequenceDiscoveryStrategy.INFORMATION_SCHEMA, + ConstraintDiscoveryStrategy.INFORMATION_SCHEMA); + + assertTrue(schema.tableExists("info_test")); + List primaryKeys = schema.getPrimaryKeys("info_test"); + assertFalse(primaryKeys.isEmpty()); + } + } + + @Test + void testReadWithNoneSequenceStrategy() throws SQLException { + execute("CREATE TABLE none_seq_test (id INTEGER AUTO_INCREMENT, PRIMARY KEY (id))"); + execute("CREATE SEQUENCE my_seq START WITH 1"); + + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection, + connection.getCatalog(), connection.getSchema(), + SequenceDiscoveryStrategy.NONE, + ConstraintDiscoveryStrategy.INFORMATION_SCHEMA); + + // With NONE strategy, sequences should not be discovered + assertFalse(schema.sequenceExists("my_seq")); + } + } + + // ---- View test ---- + + @Test + void testViewExists() throws SQLException { + execute("CREATE TABLE view_base (id INTEGER AUTO_INCREMENT, name VARCHAR(255), PRIMARY KEY (id))"); + execute("CREATE VIEW view_test AS SELECT * FROM view_base"); + + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + assertTrue(schema.tableExists("view_test")); + assertTrue(schema.getColumn("view_test", "id").isPresent()); + } + } + + // ---- Table with no columns case ---- + + @Test + void testTableWithMultipleForeignKeys() throws SQLException { + execute("CREATE TABLE parent_a (id INTEGER AUTO_INCREMENT, PRIMARY KEY (id))"); + execute("CREATE TABLE parent_b (id INTEGER AUTO_INCREMENT, PRIMARY KEY (id))"); + execute("CREATE TABLE child_multi_fk (id INTEGER AUTO_INCREMENT, a_id INTEGER, b_id INTEGER, " + + "PRIMARY KEY (id), " + + "FOREIGN KEY (a_id) REFERENCES parent_a(id), " + + "FOREIGN KEY (b_id) REFERENCES parent_b(id))"); + + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + + List foreignKeys = schema.getForeignKeys("child_multi_fk"); + assertEquals(2, foreignKeys.size()); + } + } + + @Test + void testCompoundUniqueKey() throws SQLException { + execute("CREATE TABLE compound_uk (id INTEGER AUTO_INCREMENT, col_a VARCHAR(50), col_b VARCHAR(50), " + + "PRIMARY KEY (id), UNIQUE (col_a, col_b))"); + + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + + List uniqueKeys = schema.getUniqueKeys("compound_uk"); + // Compound UK should have 2 entries with the same index name + assertTrue(uniqueKeys.size() >= 2); + } + } + + @Test + void testReadWithNullCatalogAndSchema() throws SQLException { + execute("CREATE TABLE null_ctx (id INTEGER AUTO_INCREMENT, name VARCHAR(255), PRIMARY KEY (id))"); + + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection, null, null, + SequenceDiscoveryStrategy.INFORMATION_SCHEMA, + ConstraintDiscoveryStrategy.INFORMATION_SCHEMA); + + assertTrue(schema.tableExists("null_ctx")); + } + } + + @Test + void testAutoIncrementColumnNotIncludedInPrimaryKeyMetadata() throws SQLException { + execute("CREATE TABLE auto_pk_test (id INTEGER AUTO_INCREMENT, name VARCHAR(255), PRIMARY KEY (id))"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + DbColumn idColumn = schema.getColumn("auto_pk_test", "id").orElseThrow(); + assertTrue(idColumn.autoIncrement()); + // The PK metadata should still correctly identify the auto-increment column. + List primaryKeys = schema.getPrimaryKeys("auto_pk_test"); + assertEquals(1, primaryKeys.size()); + assertEquals("ID", primaryKeys.getFirst().columnName()); + } + } + + @Test + void testForeignKeyReferencesCorrectParentColumn() throws SQLException { + execute("CREATE TABLE fk_detail_parent (id INTEGER AUTO_INCREMENT, code VARCHAR(50) UNIQUE, PRIMARY KEY (id))"); + execute("CREATE TABLE fk_detail_child (id INTEGER AUTO_INCREMENT, parent_id INTEGER, PRIMARY KEY (id), FOREIGN KEY (parent_id) REFERENCES fk_detail_parent(id))"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + List foreignKeys = schema.getForeignKeys("fk_detail_child"); + assertEquals(1, foreignKeys.size()); + DbForeignKey foreignKey = foreignKeys.getFirst(); + assertEquals("FK_DETAIL_CHILD", foreignKey.fkTableName()); + assertEquals("PARENT_ID", foreignKey.fkColumnName()); + assertEquals("FK_DETAIL_PARENT", foreignKey.pkTableName()); + assertEquals("ID", foreignKey.pkColumnName()); + } + } + + @Test + void testUniqueKeyColumnNamesAndOrdering() throws SQLException { + execute("CREATE TABLE uk_detail (id INTEGER AUTO_INCREMENT, first_name VARCHAR(50), last_name VARCHAR(50), " + + "PRIMARY KEY (id), UNIQUE (last_name, first_name))"); + try (Connection connection = getConnection()) { + DatabaseSchema schema = DatabaseSchema.read(connection); + List uniqueKeys = schema.getUniqueKeys("uk_detail"); + // The compound unique key should have 2 entries sharing the same index name. + assertEquals(2, uniqueKeys.size()); + String indexName = uniqueKeys.getFirst().indexName(); + assertTrue(uniqueKeys.stream().allMatch(uk -> uk.indexName().equals(indexName)), + "All UK entries should share the same index name"); + // Verify both columns are present. + assertTrue(uniqueKeys.stream().anyMatch(uk -> uk.columnName().equals("LAST_NAME"))); + assertTrue(uniqueKeys.stream().anyMatch(uk -> uk.columnName().equals("FIRST_NAME"))); + } + } +} diff --git a/storm-core/src/test/java/st/orm/core/template/impl/EqualitySupportTest.java b/storm-core/src/test/java/st/orm/core/template/impl/EqualitySupportTest.java new file mode 100644 index 000000000..f596fe8a6 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/template/impl/EqualitySupportTest.java @@ -0,0 +1,183 @@ +package st.orm.core.template.impl; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link EqualitySupport} covering compileIsSame and compileIsIdentical + * for various primitive and reference return types. + */ +class EqualitySupportTest { + + // Test record types with various field types. + public record IntRecord(int value) {} + public record LongRecord(long value) {} + public record BooleanRecord(boolean value) {} + public record ByteRecord(byte value) {} + public record ShortRecord(short value) {} + public record CharRecord(char value) {} + public record FloatRecord(float value) {} + public record DoubleRecord(double value) {} + public record StringRecord(String value) {} + public record NullableRecord(String value) {} + + private static java.lang.invoke.MethodHandle getHandle(Class recordType) throws Throwable { + return MethodHandles.publicLookup().findVirtual( + recordType, "value", MethodType.methodType(recordType.getRecordComponents()[0].getType())); + } + + // ---- compileIsSame tests ---- + + @Test + void testIsSameInt() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(IntRecord.class)); + assertNotNull(same); + assertTrue(same.isSame(new IntRecord(42), new IntRecord(42))); + assertFalse(same.isSame(new IntRecord(42), new IntRecord(99))); + } + + @Test + void testIsSameLong() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(LongRecord.class)); + assertTrue(same.isSame(new LongRecord(100L), new LongRecord(100L))); + assertFalse(same.isSame(new LongRecord(100L), new LongRecord(200L))); + } + + @Test + void testIsSameBoolean() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(BooleanRecord.class)); + assertTrue(same.isSame(new BooleanRecord(true), new BooleanRecord(true))); + assertFalse(same.isSame(new BooleanRecord(true), new BooleanRecord(false))); + } + + @Test + void testIsSameByte() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(ByteRecord.class)); + assertTrue(same.isSame(new ByteRecord((byte) 1), new ByteRecord((byte) 1))); + assertFalse(same.isSame(new ByteRecord((byte) 1), new ByteRecord((byte) 2))); + } + + @Test + void testIsSameShort() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(ShortRecord.class)); + assertTrue(same.isSame(new ShortRecord((short) 10), new ShortRecord((short) 10))); + assertFalse(same.isSame(new ShortRecord((short) 10), new ShortRecord((short) 20))); + } + + @Test + void testIsSameChar() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(CharRecord.class)); + assertTrue(same.isSame(new CharRecord('A'), new CharRecord('A'))); + assertFalse(same.isSame(new CharRecord('A'), new CharRecord('B'))); + } + + @Test + void testIsSameFloat() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(FloatRecord.class)); + assertTrue(same.isSame(new FloatRecord(3.14f), new FloatRecord(3.14f))); + assertFalse(same.isSame(new FloatRecord(3.14f), new FloatRecord(2.72f))); + } + + @Test + void testIsSameFloatNaN() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(FloatRecord.class)); + // NaN should be equal to NaN with floatToIntBits comparison. + assertTrue(same.isSame(new FloatRecord(Float.NaN), new FloatRecord(Float.NaN))); + } + + @Test + void testIsSameDouble() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(DoubleRecord.class)); + assertTrue(same.isSame(new DoubleRecord(2.718), new DoubleRecord(2.718))); + assertFalse(same.isSame(new DoubleRecord(2.718), new DoubleRecord(3.14))); + } + + @Test + void testIsSameDoubleNaN() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(DoubleRecord.class)); + // NaN should be equal to NaN with doubleToLongBits comparison. + assertTrue(same.isSame(new DoubleRecord(Double.NaN), new DoubleRecord(Double.NaN))); + } + + @Test + void testIsSameString() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(StringRecord.class)); + assertTrue(same.isSame(new StringRecord("hello"), new StringRecord("hello"))); + assertFalse(same.isSame(new StringRecord("hello"), new StringRecord("world"))); + } + + @Test + void testIsSameStringNull() throws Throwable { + var same = EqualitySupport.compileIsSame(getHandle(NullableRecord.class)); + assertTrue(same.isSame(new NullableRecord(null), new NullableRecord(null))); + assertFalse(same.isSame(new NullableRecord("hello"), new NullableRecord(null))); + assertFalse(same.isSame(new NullableRecord(null), new NullableRecord("hello"))); + } + + // ---- compileIsIdentical tests ---- + + @Test + void testIsIdenticalInt() throws Throwable { + var identical = EqualitySupport.compileIsIdentical(getHandle(IntRecord.class)); + // For primitives, identical wraps isSame. + assertTrue(identical.isIdentical(new IntRecord(42), new IntRecord(42))); + assertFalse(identical.isIdentical(new IntRecord(42), new IntRecord(99))); + } + + @Test + void testIsIdenticalString() throws Throwable { + var identical = EqualitySupport.compileIsIdentical(getHandle(StringRecord.class)); + String sharedValue = "shared"; + // Reference identity: same String instance. + assertTrue(identical.isIdentical(new StringRecord(sharedValue), new StringRecord(sharedValue))); + } + + @Test + void testIsIdenticalStringDifferentInstances() throws Throwable { + var identical = EqualitySupport.compileIsIdentical(getHandle(StringRecord.class)); + // Different String instances, even if equal by value, should not be identical. + String valueA = new String("test"); + String valueB = new String("test"); + assertFalse(identical.isIdentical(new StringRecord(valueA), new StringRecord(valueB))); + } + + @Test + void testIsIdenticalNulls() throws Throwable { + var identical = EqualitySupport.compileIsIdentical(getHandle(NullableRecord.class)); + // Both null: should be identical (same reference: null == null). + assertTrue(identical.isIdentical(new NullableRecord(null), new NullableRecord(null))); + } + + @Test + void testIsIdenticalLong() throws Throwable { + var identical = EqualitySupport.compileIsIdentical(getHandle(LongRecord.class)); + assertTrue(identical.isIdentical(new LongRecord(100L), new LongRecord(100L))); + assertFalse(identical.isIdentical(new LongRecord(100L), new LongRecord(200L))); + } + + @Test + void testIsIdenticalBoolean() throws Throwable { + var identical = EqualitySupport.compileIsIdentical(getHandle(BooleanRecord.class)); + assertTrue(identical.isIdentical(new BooleanRecord(true), new BooleanRecord(true))); + assertFalse(identical.isIdentical(new BooleanRecord(true), new BooleanRecord(false))); + } + + @Test + void testIsIdenticalFloat() throws Throwable { + var identical = EqualitySupport.compileIsIdentical(getHandle(FloatRecord.class)); + assertTrue(identical.isIdentical(new FloatRecord(1.0f), new FloatRecord(1.0f))); + assertFalse(identical.isIdentical(new FloatRecord(1.0f), new FloatRecord(2.0f))); + } + + @Test + void testIsIdenticalDouble() throws Throwable { + var identical = EqualitySupport.compileIsIdentical(getHandle(DoubleRecord.class)); + assertTrue(identical.isIdentical(new DoubleRecord(1.0), new DoubleRecord(1.0))); + assertFalse(identical.isIdentical(new DoubleRecord(1.0), new DoubleRecord(2.0))); + } +} diff --git a/storm-core/src/test/java/st/orm/core/template/impl/MetamodelFactoryTest.java b/storm-core/src/test/java/st/orm/core/template/impl/MetamodelFactoryTest.java new file mode 100644 index 000000000..8df7c97e7 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/template/impl/MetamodelFactoryTest.java @@ -0,0 +1,319 @@ +package st.orm.core.template.impl; + +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; +import st.orm.Entity; +import st.orm.FK; +import st.orm.Inline; +import st.orm.Metamodel; +import st.orm.PK; +import st.orm.Ref; + +/** + * Tests for {@link MetamodelFactory} covering root model creation, path-based model resolution, + * capitalize utility, and flatten behavior. + */ +class MetamodelFactoryTest { + + // ---- Test model types ---- + + public record SimpleEntity( + @PK Integer id, + String name + ) implements Entity {} + + public record InlineAddress(String street, String zipCode) {} + + public record EntityWithInline( + @PK Integer id, + @Inline InlineAddress address + ) implements Entity {} + + public record ReferencedEntity( + @PK Integer id, + String name + ) implements Entity {} + + public record EntityWithFk( + @PK Integer id, + @FK ReferencedEntity ref + ) implements Entity {} + + public record EntityWithRefFk( + @PK Integer id, + @FK Ref ref + ) implements Entity {} + + // ---- capitalize tests ---- + + @Test + void testCapitalizeNormal() { + assertEquals("Name", MetamodelFactory.capitalize("name")); + } + + @Test + void testCapitalizeAlreadyCapitalized() { + assertEquals("Name", MetamodelFactory.capitalize("Name")); + } + + @Test + void testCapitalizeSingleChar() { + assertEquals("A", MetamodelFactory.capitalize("a")); + } + + @Test + void testCapitalizeEmpty() { + assertEquals("", MetamodelFactory.capitalize("")); + } + + @Test + void testCapitalizeNull() { + assertEquals(null, MetamodelFactory.capitalize(null)); + } + + // ---- root() tests ---- + + @Test + void testRootReturnsMetamodel() { + Metamodel root = MetamodelFactory.root(SimpleEntity.class); + assertNotNull(root); + assertEquals(SimpleEntity.class, root.fieldType()); + } + + @Test + void testRootCachesSameInstance() { + Metamodel root1 = MetamodelFactory.root(SimpleEntity.class); + Metamodel root2 = MetamodelFactory.root(SimpleEntity.class); + assertSame(root1, root2); + } + + @Test + void testRootGetValueReturnsSameRecord() { + Metamodel root = MetamodelFactory.root(SimpleEntity.class); + SimpleEntity entity = new SimpleEntity(1, "test"); + assertEquals(entity, root.getValue(entity)); + } + + @Test + void testRootIsIdenticalReturnsTrueForSameInstance() { + Metamodel root = MetamodelFactory.root(SimpleEntity.class); + SimpleEntity entity = new SimpleEntity(1, "test"); + assertTrue(root.isIdentical(entity, entity)); + } + + @Test + void testRootIsIdenticalReturnsFalseForDifferentInstances() { + Metamodel root = MetamodelFactory.root(SimpleEntity.class); + SimpleEntity entityA = new SimpleEntity(1, "test"); + SimpleEntity entityB = new SimpleEntity(1, "test"); + assertFalse(root.isIdentical(entityA, entityB)); + } + + @Test + void testRootIsSameReturnsTrueForSamePk() { + Metamodel root = MetamodelFactory.root(SimpleEntity.class); + SimpleEntity entityA = new SimpleEntity(1, "a"); + SimpleEntity entityB = new SimpleEntity(1, "b"); + assertTrue(root.isSame(entityA, entityB)); + } + + @Test + void testRootIsSameReturnsFalseForDifferentPk() { + Metamodel root = MetamodelFactory.root(SimpleEntity.class); + SimpleEntity entityA = new SimpleEntity(1, "a"); + SimpleEntity entityB = new SimpleEntity(2, "a"); + assertFalse(root.isSame(entityA, entityB)); + } + + // ---- of() tests (path-based metamodel) ---- + + @Test + void testOfSimpleField() { + Metamodel model = MetamodelFactory.of(SimpleEntity.class, "name"); + assertNotNull(model); + assertEquals(String.class, model.fieldType()); + assertEquals("name", model.field()); + assertEquals("", model.path()); + assertTrue(model.isColumn()); + } + + @Test + void testOfPkField() { + Metamodel model = MetamodelFactory.of(SimpleEntity.class, "id"); + assertNotNull(model); + assertEquals(Integer.class, model.fieldType()); + assertEquals("id", model.field()); + assertTrue(model.isColumn()); + } + + @Test + void testOfFkField() { + Metamodel model = MetamodelFactory.of(EntityWithFk.class, "ref"); + assertNotNull(model); + assertEquals(ReferencedEntity.class, model.fieldType()); + assertEquals("ref", model.field()); + assertTrue(model.isColumn()); + } + + @Test + void testOfRefFkField() { + Metamodel model = MetamodelFactory.of(EntityWithRefFk.class, "ref"); + assertNotNull(model); + assertEquals(ReferencedEntity.class, model.fieldType()); + assertEquals("ref", model.field()); + assertTrue(model.isColumn()); + } + + @Test + void testOfNestedFkField() { + Metamodel model = MetamodelFactory.of(EntityWithFk.class, "ref.name"); + assertNotNull(model); + assertEquals(String.class, model.fieldType()); + assertEquals("name", model.field()); + assertEquals("ref", model.path()); + } + + @Test + void testOfInlineField() { + Metamodel model = MetamodelFactory.of(EntityWithInline.class, "address"); + assertNotNull(model); + assertEquals(InlineAddress.class, model.fieldType()); + assertTrue(model.isInline()); + assertFalse(model.isColumn()); + } + + @Test + void testOfInlineNestedField() { + Metamodel model = MetamodelFactory.of(EntityWithInline.class, "address.street"); + assertNotNull(model); + assertEquals(String.class, model.fieldType()); + assertEquals("address.street", model.field()); + assertTrue(model.isColumn()); + } + + @Test + void testOfCachesSameInstance() { + Metamodel model1 = MetamodelFactory.of(SimpleEntity.class, "name"); + Metamodel model2 = MetamodelFactory.of(SimpleEntity.class, "name"); + assertSame(model1, model2); + } + + // ---- flatten() tests ---- + + @Test + void testFlattenNonInlineReturnsSingleton() { + Metamodel model = MetamodelFactory.of(SimpleEntity.class, "name"); + List> flattened = MetamodelFactory.flatten(model); + assertEquals(1, flattened.size()); + assertSame(model, flattened.get(0)); + } + + @Test + void testFlattenInlineReturnsLeafFields() { + Metamodel model = MetamodelFactory.of(EntityWithInline.class, "address"); + assertTrue(model.isInline()); + List> flattened = MetamodelFactory.flatten(model); + assertEquals(2, flattened.size()); + // Should contain street and zipCode. + assertTrue(flattened.stream().anyMatch(m -> m.field().endsWith("street"))); + assertTrue(flattened.stream().anyMatch(m -> m.field().endsWith("zipCode"))); + } + + // ---- getValue() tests ---- + + @Test + void testOfGetValueSimpleField() { + Metamodel model = MetamodelFactory.of(SimpleEntity.class, "name"); + SimpleEntity entity = new SimpleEntity(1, "hello"); + assertEquals("hello", model.getValue(entity)); + } + + @Test + void testOfGetValuePkField() { + Metamodel model = MetamodelFactory.of(SimpleEntity.class, "id"); + SimpleEntity entity = new SimpleEntity(42, "hello"); + assertEquals(42, model.getValue(entity)); + } + + @Test + void testOfGetValueFkField() { + Metamodel model = MetamodelFactory.of(EntityWithFk.class, "ref"); + ReferencedEntity referenced = new ReferencedEntity(10, "referenced"); + EntityWithFk entity = new EntityWithFk(1, referenced); + assertEquals(referenced, model.getValue(entity)); + } + + @Test + void testOfGetValueNestedPath() { + Metamodel model = MetamodelFactory.of(EntityWithFk.class, "ref.name"); + ReferencedEntity referenced = new ReferencedEntity(10, "nested_name"); + EntityWithFk entity = new EntityWithFk(1, referenced); + assertEquals("nested_name", model.getValue(entity)); + } + + @Test + void testOfGetValueNullIntermediateReturnsNull() { + Metamodel model = MetamodelFactory.of(EntityWithFk.class, "ref.name"); + EntityWithFk entity = new EntityWithFk(1, null); + // Null-safe getter should return null when intermediate is null. + assertEquals(null, model.getValue(entity)); + } + + // ---- isSame / isIdentical tests ---- + + @Test + void testOfIsSameForScalarField() { + Metamodel model = MetamodelFactory.of(SimpleEntity.class, "name"); + SimpleEntity entityA = new SimpleEntity(1, "same"); + SimpleEntity entityB = new SimpleEntity(2, "same"); + assertTrue(model.isSame(entityA, entityB)); + } + + @Test + void testOfIsSameForDifferentScalarField() { + Metamodel model = MetamodelFactory.of(SimpleEntity.class, "name"); + SimpleEntity entityA = new SimpleEntity(1, "a"); + SimpleEntity entityB = new SimpleEntity(1, "b"); + assertFalse(model.isSame(entityA, entityB)); + } + + @Test + void testOfIsIdenticalForSameReference() { + Metamodel model = MetamodelFactory.of(EntityWithFk.class, "ref"); + ReferencedEntity shared = new ReferencedEntity(10, "shared"); + EntityWithFk entityA = new EntityWithFk(1, shared); + EntityWithFk entityB = new EntityWithFk(2, shared); + assertTrue(model.isIdentical(entityA, entityB)); + } + + @Test + void testOfIsIdenticalForDifferentReference() { + Metamodel model = MetamodelFactory.of(EntityWithFk.class, "ref"); + EntityWithFk entityA = new EntityWithFk(1, new ReferencedEntity(10, "a")); + EntityWithFk entityB = new EntityWithFk(2, new ReferencedEntity(10, "a")); + assertFalse(model.isIdentical(entityA, entityB)); + } + + @Test + void testOfIsSameForFkFieldComparesById() { + Metamodel model = MetamodelFactory.of(EntityWithFk.class, "ref"); + EntityWithFk entityA = new EntityWithFk(1, new ReferencedEntity(10, "a")); + EntityWithFk entityB = new EntityWithFk(2, new ReferencedEntity(10, "b")); + // isSame for Data fields compares by PK, so both have ref.id=10 -> same. + assertTrue(model.isSame(entityA, entityB)); + } + + @Test + void testOfIsSameForFkFieldDifferentPk() { + Metamodel model = MetamodelFactory.of(EntityWithFk.class, "ref"); + EntityWithFk entityA = new EntityWithFk(1, new ReferencedEntity(10, "a")); + EntityWithFk entityB = new EntityWithFk(2, new ReferencedEntity(20, "a")); + assertFalse(model.isSame(entityA, entityB)); + } +} diff --git a/storm-core/src/test/java/st/orm/core/template/impl/PrimitiveMapperTest.java b/storm-core/src/test/java/st/orm/core/template/impl/PrimitiveMapperTest.java new file mode 100644 index 000000000..fc27cac5b --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/template/impl/PrimitiveMapperTest.java @@ -0,0 +1,119 @@ +package st.orm.core.template.impl; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import st.orm.PersistenceException; +import st.orm.core.template.SqlTemplateException; + +/** + * Tests for {@link PrimitiveMapper} covering factory creation for primitive types. + */ +class PrimitiveMapperTest { + + @Test + void testIntFactory() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, int.class); + assertTrue(factory.isPresent()); + ObjectMapper mapper = factory.get(); + assertArrayEquals(new Class[] { int.class }, mapper.getParameterTypes()); + assertEquals(42, mapper.newInstance(new Object[] { 42 })); + } + + @Test + void testIntFactoryWithLongInput() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, int.class); + assertTrue(factory.isPresent()); + assertEquals(42, factory.get().newInstance(new Object[] { 42L })); + } + + @Test + void testLongFactory() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, long.class); + assertTrue(factory.isPresent()); + assertEquals(100L, factory.get().newInstance(new Object[] { 100L })); + } + + @Test + void testLongFactoryWithIntInput() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, long.class); + assertTrue(factory.isPresent()); + assertEquals(100L, factory.get().newInstance(new Object[] { 100 })); + } + + @Test + void testFloatFactory() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, float.class); + assertTrue(factory.isPresent()); + assertEquals(3.14f, factory.get().newInstance(new Object[] { 3.14f })); + } + + @Test + void testFloatFactoryWithDoubleInput() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, float.class); + assertTrue(factory.isPresent()); + assertEquals(3.14f, factory.get().newInstance(new Object[] { 3.14 })); + } + + @Test + void testDoubleFactory() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, double.class); + assertTrue(factory.isPresent()); + assertEquals(2.718, factory.get().newInstance(new Object[] { 2.718 })); + } + + @Test + void testBooleanFactory() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, boolean.class); + assertTrue(factory.isPresent()); + assertEquals(true, factory.get().newInstance(new Object[] { true })); + } + + @Test + void testMultipleColumnCountReturnsEmpty() { + Optional> factory = PrimitiveMapper.getFactory(2, int.class); + assertFalse(factory.isPresent()); + } + + @Test + void testZeroColumnCountReturnsEmpty() { + Optional> factory = PrimitiveMapper.getFactory(0, int.class); + assertFalse(factory.isPresent()); + } + + @Test + void testNonPrimitiveTypeThrows() { + assertThrows(PersistenceException.class, () -> PrimitiveMapper.getFactory(1, Integer.class)); + } + + @Test + void testNonPrimitiveStringTypeThrows() { + assertThrows(PersistenceException.class, () -> PrimitiveMapper.getFactory(1, String.class)); + } + + @Test + void testByteFactory() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, byte.class); + assertTrue(factory.isPresent()); + assertEquals((byte) 42, factory.get().newInstance(new Object[] { (byte) 42 })); + } + + @Test + void testShortFactory() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, short.class); + assertTrue(factory.isPresent()); + assertEquals((short) 42, factory.get().newInstance(new Object[] { (short) 42 })); + } + + @Test + void testCharFactory() throws SqlTemplateException { + Optional> factory = PrimitiveMapper.getFactory(1, char.class); + assertTrue(factory.isPresent()); + assertEquals('A', factory.get().newInstance(new Object[] { 'A' })); + } +} diff --git a/storm-core/src/test/java/st/orm/core/template/impl/RecordReflectionTest.java b/storm-core/src/test/java/st/orm/core/template/impl/RecordReflectionTest.java new file mode 100644 index 000000000..283ae3356 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/template/impl/RecordReflectionTest.java @@ -0,0 +1,449 @@ +package st.orm.core.template.impl; + +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; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import st.orm.Data; +import st.orm.DbColumn; +import st.orm.DbTable; +import st.orm.Entity; +import st.orm.FK; +import st.orm.GenerationStrategy; +import st.orm.Inline; +import st.orm.PK; +import st.orm.Ref; +import st.orm.Version; +import st.orm.core.template.SqlTemplateException; +import st.orm.mapping.RecordField; + +/** + * Tests for {@link RecordReflection} utility methods covering field lookup, PK/FK/Version + * discovery, table and column naming, and type checking. + */ +class RecordReflectionTest { + + // ---- Test model types ---- + + public record SimpleEntity( + @PK Integer id, + @Nonnull String name + ) implements Entity {} + + public record EntityWithVersion( + @PK Integer id, + @Nonnull String name, + @Version int version + ) implements Entity {} + + public record InlineAddress(String street, String zipCode) {} + + public record EntityWithInline( + @PK Integer id, + @Inline InlineAddress address, + @Nonnull String name + ) implements Entity {} + + public record ReferencedEntity( + @PK Integer id, + @Nonnull String name + ) implements Entity {} + + public record EntityWithFk( + @PK Integer id, + @FK ReferencedEntity ref + ) implements Entity {} + + public record EntityWithRefFk( + @PK Integer id, + @Nullable @FK Ref ref + ) implements Entity {} + + public record CompoundPk(int partA, int partB) {} + + public record EntityWithCompoundPk( + @PK CompoundPk id, + @Nonnull String name + ) implements Entity {} + + @DbTable("custom_table") + public record CustomTableEntity( + @PK Integer id, + @Nonnull String name + ) implements Entity {} + + @DbTable(value = "annotated_table", schema = "my_schema") + public record SchemaTableEntity( + @PK Integer id, + @Nonnull String name + ) implements Entity {} + + public record EntityWithDbColumn( + @PK @DbColumn("pk_col") Integer id, + @DbColumn("name_col") String name + ) implements Entity {} + + public record EntityWithFkPk( + @PK @FK ReferencedEntity ref, + @Nonnull String extra + ) implements Entity {} + + public record NoVersionEntity( + @PK Integer id, + String name + ) implements Entity {} + + public record NestedRecord( + String inner + ) {} + + public record EntityWithNested( + @PK Integer id, + @Inline NestedRecord nested + ) implements Entity {} + + public record SimpleData(String value) implements Data {} + + public record IdentityGenEntity( + @PK(generation = GenerationStrategy.IDENTITY) Integer id, + String name + ) implements Entity {} + + public record NoneGenEntity( + @PK(generation = GenerationStrategy.NONE) Integer id, + String name + ) implements Entity {} + + public record SeqGenEntity( + @PK(generation = GenerationStrategy.SEQUENCE, sequence = "my_seq") Integer id, + String name + ) implements Entity {} + + // ---- isRecord tests ---- + + @Test + void testIsRecordForRecord() { + assertTrue(RecordReflection.isRecord(SimpleEntity.class)); + } + + @Test + void testIsRecordForNonRecord() { + assertFalse(RecordReflection.isRecord(String.class)); + } + + @Test + void testIsRecordForInlineRecord() { + assertTrue(RecordReflection.isRecord(InlineAddress.class)); + } + + // ---- getRecordType tests ---- + + @Test + void testGetRecordType() { + var recordType = RecordReflection.getRecordType(SimpleEntity.class); + assertNotNull(recordType); + assertEquals(SimpleEntity.class, recordType.type()); + } + + // ---- getRecordFields tests ---- + + @Test + void testGetRecordFields() { + var fields = RecordReflection.getRecordFields(SimpleEntity.class); + assertEquals(2, fields.size()); + assertEquals("id", fields.get(0).name()); + assertEquals("name", fields.get(1).name()); + } + + @Test + void testGetRecordFieldsForEntityWithFk() { + var fields = RecordReflection.getRecordFields(EntityWithFk.class); + assertEquals(2, fields.size()); + assertEquals("id", fields.get(0).name()); + assertEquals("ref", fields.get(1).name()); + } + + // ---- getRecordField (path-based lookup) tests ---- + + @Test + void testGetRecordFieldSimple() throws SqlTemplateException { + RecordField field = RecordReflection.getRecordField(SimpleEntity.class, "name"); + assertEquals("name", field.name()); + assertEquals(String.class, field.type()); + } + + @Test + void testGetRecordFieldPk() throws SqlTemplateException { + RecordField field = RecordReflection.getRecordField(SimpleEntity.class, "id"); + assertEquals("id", field.name()); + assertTrue(field.isAnnotationPresent(PK.class)); + } + + @Test + void testGetRecordFieldNestedPath() throws SqlTemplateException { + RecordField field = RecordReflection.getRecordField(EntityWithNested.class, "nested.inner"); + assertEquals("inner", field.name()); + assertEquals(String.class, field.type()); + } + + @Test + void testGetRecordFieldEmptyPathThrows() { + assertThrows(SqlTemplateException.class, + () -> RecordReflection.getRecordField(SimpleEntity.class, "")); + } + + @Test + void testGetRecordFieldNonexistentFieldThrows() { + assertThrows(SqlTemplateException.class, + () -> RecordReflection.getRecordField(SimpleEntity.class, "nonexistent")); + } + + @Test + void testGetRecordFieldNestedNonRecordThrows() { + assertThrows(SqlTemplateException.class, + () -> RecordReflection.getRecordField(SimpleEntity.class, "name.something")); + } + + // ---- findPkField tests ---- + + @Test + void testFindPkField() { + Optional pkField = RecordReflection.findPkField(SimpleEntity.class); + assertTrue(pkField.isPresent()); + assertEquals("id", pkField.get().name()); + } + + @Test + void testFindPkFieldCompound() { + Optional pkField = RecordReflection.findPkField(EntityWithCompoundPk.class); + assertTrue(pkField.isPresent()); + assertEquals("id", pkField.get().name()); + assertEquals(CompoundPk.class, pkField.get().type()); + } + + // ---- getNestedPkFields tests ---- + + @Test + void testGetNestedPkFieldsSimple() { + List pkFields = RecordReflection.getNestedPkFields(SimpleEntity.class).toList(); + assertEquals(1, pkFields.size()); + assertEquals("id", pkFields.get(0).name()); + } + + @Test + void testGetNestedPkFieldsCompound() { + List pkFields = RecordReflection.getNestedPkFields(EntityWithCompoundPk.class).toList(); + assertEquals(2, pkFields.size()); + assertEquals("partA", pkFields.get(0).name()); + assertEquals("partB", pkFields.get(1).name()); + } + + @Test + void testGetNestedPkFieldsFkPk() { + // When PK is also FK, the field itself is returned (not the nested PK of the referenced entity). + List pkFields = RecordReflection.getNestedPkFields(EntityWithFkPk.class).toList(); + assertEquals(1, pkFields.size()); + assertEquals("ref", pkFields.get(0).name()); + } + + // ---- getFkFields tests ---- + + @Test + void testGetFkFields() { + List fkFields = RecordReflection.getFkFields(EntityWithFk.class).toList(); + assertEquals(1, fkFields.size()); + assertEquals("ref", fkFields.get(0).name()); + } + + @Test + void testGetFkFieldsWithRef() { + List fkFields = RecordReflection.getFkFields(EntityWithRefFk.class).toList(); + assertEquals(1, fkFields.size()); + assertEquals("ref", fkFields.get(0).name()); + } + + @Test + void testGetFkFieldsNoFk() { + List fkFields = RecordReflection.getFkFields(SimpleEntity.class).toList(); + assertTrue(fkFields.isEmpty()); + } + + // ---- getVersionField tests ---- + + @Test + void testGetVersionField() { + Optional versionField = RecordReflection.getVersionField(EntityWithVersion.class); + assertTrue(versionField.isPresent()); + assertEquals("version", versionField.get().name()); + } + + @Test + void testGetVersionFieldAbsent() { + Optional versionField = RecordReflection.getVersionField(NoVersionEntity.class); + assertFalse(versionField.isPresent()); + } + + // ---- getGenerationStrategy tests ---- + + @Test + void testGetGenerationStrategyIdentity() throws SqlTemplateException { + RecordField pkField = RecordReflection.getRecordField(IdentityGenEntity.class, "id"); + assertEquals(GenerationStrategy.IDENTITY, RecordReflection.getGenerationStrategy(pkField)); + } + + @Test + void testGetGenerationStrategyNone() throws SqlTemplateException { + RecordField pkField = RecordReflection.getRecordField(NoneGenEntity.class, "id"); + assertEquals(GenerationStrategy.NONE, RecordReflection.getGenerationStrategy(pkField)); + } + + @Test + void testGetGenerationStrategySequence() throws SqlTemplateException { + RecordField pkField = RecordReflection.getRecordField(SeqGenEntity.class, "id"); + assertEquals(GenerationStrategy.SEQUENCE, RecordReflection.getGenerationStrategy(pkField)); + } + + @Test + void testGetGenerationStrategyNonPkField() throws SqlTemplateException { + RecordField nameField = RecordReflection.getRecordField(SimpleEntity.class, "name"); + assertEquals(GenerationStrategy.NONE, RecordReflection.getGenerationStrategy(nameField)); + } + + // ---- getSequence tests ---- + + @Test + void testGetSequence() throws SqlTemplateException { + RecordField pkField = RecordReflection.getRecordField(SeqGenEntity.class, "id"); + assertEquals("my_seq", RecordReflection.getSequence(pkField)); + } + + @Test + void testGetSequenceEmpty() throws SqlTemplateException { + RecordField pkField = RecordReflection.getRecordField(IdentityGenEntity.class, "id"); + assertEquals("", RecordReflection.getSequence(pkField)); + } + + @Test + void testGetSequenceNonPk() throws SqlTemplateException { + RecordField nameField = RecordReflection.getRecordField(SimpleEntity.class, "name"); + assertEquals("", RecordReflection.getSequence(nameField)); + } + + // ---- isTypePresent tests ---- + + @Test + void testIsTypePresentSelf() throws SqlTemplateException { + assertTrue(RecordReflection.isTypePresent(SimpleEntity.class, SimpleEntity.class)); + } + + @Test + void testIsTypePresentFk() throws SqlTemplateException { + assertTrue(RecordReflection.isTypePresent(EntityWithFk.class, ReferencedEntity.class)); + } + + @Test + void testIsTypePresentAbsent() throws SqlTemplateException { + assertFalse(RecordReflection.isTypePresent(SimpleEntity.class, ReferencedEntity.class)); + } + + // ---- getRefDataType tests ---- + + @Test + void testGetRefDataType() throws SqlTemplateException { + RecordField refField = RecordReflection.getRecordField(EntityWithRefFk.class, "ref"); + Class refDataType = RecordReflection.getRefDataType(refField); + assertEquals(ReferencedEntity.class, refDataType); + } + + // ---- getRefPkType tests ---- + + @Test + void testGetRefPkType() throws SqlTemplateException { + RecordField refField = RecordReflection.getRecordField(EntityWithRefFk.class, "ref"); + Class refPkType = RecordReflection.getRefPkType(refField); + assertEquals(Integer.class, refPkType); + } + + // ---- getTableName tests ---- + + @Test + void testGetTableNameDefault() throws SqlTemplateException { + var tableName = RecordReflection.getTableName(SimpleEntity.class, type -> { + // Default resolver: camelCase to snake_case. + return type.type().getSimpleName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(); + }); + assertNotNull(tableName); + // SimpleEntity -> simple_entity + assertEquals("simple_entity", tableName.name()); + } + + @Test + void testGetTableNameWithDbTable() throws SqlTemplateException { + var tableName = RecordReflection.getTableName(CustomTableEntity.class, type -> type.type().getSimpleName()); + assertEquals("custom_table", tableName.name()); + } + + @Test + void testGetTableNameWithSchema() throws SqlTemplateException { + var tableName = RecordReflection.getTableName(SchemaTableEntity.class, type -> type.type().getSimpleName()); + assertEquals("annotated_table", tableName.name()); + assertEquals("my_schema", tableName.schema()); + } + + // ---- getColumnName tests ---- + + @Test + void testGetColumnNameWithDbColumn() throws SqlTemplateException { + RecordField idField = RecordReflection.getRecordField(EntityWithDbColumn.class, "id"); + ColumnName columnName = RecordReflection.getColumnName(idField, field -> field.name()); + assertEquals("pk_col", columnName.name()); + } + + @Test + void testGetColumnNameDefault() throws SqlTemplateException { + RecordField nameField = RecordReflection.getRecordField(SimpleEntity.class, "name"); + ColumnName columnName = RecordReflection.getColumnName(nameField, field -> field.name()); + assertEquals("name", columnName.name()); + } + + // ---- findRecordField tests ---- + + @Test + void testFindRecordFieldByType() throws SqlTemplateException { + var fields = RecordReflection.getRecordFields(EntityWithFk.class); + Optional found = RecordReflection.findRecordField(fields, ReferencedEntity.class); + assertTrue(found.isPresent()); + assertEquals("ref", found.get().name()); + } + + @Test + void testFindRecordFieldByTypeRef() throws SqlTemplateException { + var fields = RecordReflection.getRecordFields(EntityWithRefFk.class); + Optional found = RecordReflection.findRecordField(fields, ReferencedEntity.class); + assertTrue(found.isPresent()); + assertEquals("ref", found.get().name()); + } + + @Test + void testFindRecordFieldByTypeMissing() throws SqlTemplateException { + var fields = RecordReflection.getRecordFields(SimpleEntity.class); + Optional found = RecordReflection.findRecordField(fields, ReferencedEntity.class); + assertFalse(found.isPresent()); + } + + // ---- Generation strategy for compound PK ---- + + @Test + void testGetGenerationStrategyCompoundPk() throws SqlTemplateException { + RecordField pkField = RecordReflection.getRecordField(EntityWithCompoundPk.class, "id"); + // Compound PK (record type) always returns NONE. + assertEquals(GenerationStrategy.NONE, RecordReflection.getGenerationStrategy(pkField)); + } +} diff --git a/storm-core/src/test/java/st/orm/core/template/impl/RecordValidationTest.java b/storm-core/src/test/java/st/orm/core/template/impl/RecordValidationTest.java new file mode 100644 index 000000000..b1f59f2c6 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/template/impl/RecordValidationTest.java @@ -0,0 +1,418 @@ +package st.orm.core.template.impl; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.List; +import org.junit.jupiter.api.Test; +import st.orm.Data; +import st.orm.Entity; +import st.orm.FK; +import st.orm.Inline; +import st.orm.PK; +import st.orm.Projection; +import st.orm.ProjectionQuery; +import st.orm.Ref; +import st.orm.Version; +import st.orm.core.template.SqlTemplate.NamedParameter; +import st.orm.core.template.SqlTemplate.Parameter; +import st.orm.core.template.SqlTemplate.PositionalParameter; +import st.orm.core.template.SqlTemplateException; + +/** + * Tests for {@link RecordValidation} to cover data type validation, parameter validation, and + * various edge cases in record structure validation. + */ +class RecordValidationTest { + + // ---- Valid entity types ---- + + public record SimpleEntity( + @PK Integer id, + @Nonnull String name + ) implements Entity {} + + public record LongPkEntity( + @PK Long id, + @Nonnull String name + ) implements Entity {} + + public record StringPkEntity( + @PK String id, + @Nonnull String name + ) implements Entity {} + + // ---- Invalid entity types ---- + + public record NoPkEntity( + @Nonnull String name + ) implements Entity {} + + // We need a separate class to test multiple PKs + public record MultiplePkEntity( + @PK Integer id, + @PK Integer id2, + @Nonnull String name + ) implements Entity {} + + // ---- Inline record validation ---- + + public record InlineData(String street, String zipCode) {} + + public record EntityWithInline( + @PK Integer id, + @Nonnull @Inline InlineData address + ) implements Entity {} + + // ---- Invalid PK type ---- + + public record FloatPkEntity( + @PK Float id, + @Nonnull String name + ) implements Entity {} + + public record DoublePkTypeEntity( + @PK Double id, + @Nonnull String name + ) implements Entity {} + + // ---- FK validation ---- + + public record ReferencedEntity( + @PK Integer id, + @Nonnull String name + ) implements Entity {} + + public record EntityWithFk( + @PK Integer id, + @FK ReferencedEntity ref + ) implements Entity {} + + public record EntityWithRefFk( + @PK Integer id, + @Nullable @FK Ref ref + ) implements Entity {} + + // ---- FK inlined (invalid) ---- + + public record InlinedFkEntity( + @PK Integer id, + @FK @Inline InlineData address + ) implements Entity {} + + // ---- Version validation ---- + + public record EntityWithVersion( + @PK Integer id, + @Nonnull String name, + @Version int version + ) implements Entity {} + + public record MultipleVersionEntity( + @PK Integer id, + @Version int version1, + @Version int version2 + ) implements Entity {} + + // ---- Ref without FK ---- + + public record RefWithoutFk( + @PK Integer id, + @Nonnull Ref ref + ) implements Entity {} + + // ---- Entity inside entity without FK ---- + + public record EntityInsideEntity( + @PK Integer id, + ReferencedEntity nested + ) implements Entity {} + + // ---- Inline with PK (invalid) ---- + + public record InlineWithPk( + @PK Integer id, + @Nonnull String name + ) implements Entity {} + + public record EntityWithInlineHavingPk( + @PK Integer id, + InlineWithPk nested + ) implements Entity {} + + // ---- Projection tests ---- + + public record SimpleProjection( + @PK Integer id, + @Nonnull String name + ) implements Projection {} + + public record ProjectionInsideProjection( + @PK Integer id, + SimpleProjection nested + ) implements Projection {} + + public record EntityInsideProjection( + @PK Integer id, + ReferencedEntity nested + ) implements Projection {} + + // ---- ProjectionQuery ---- + + @ProjectionQuery("SELECT id, name FROM simple_entity") + public record ValidProjectionQuery( + @PK Integer id, + @Nonnull String name + ) implements Projection {} + + @ProjectionQuery("") + public record EmptyProjectionQuery( + @PK Integer id, + @Nonnull String name + ) implements Projection {} + + @ProjectionQuery("SELECT id, name FROM simple_entity") + public record ProjectionQueryOnEntity( + @PK Integer id, + @Nonnull String name + ) implements Entity {} + + // ---- Data class wrapping entities ---- + + public record DataWrappingEntity( + ReferencedEntity entity + ) implements Data {} + + // ---- Non-Data type ---- + + public record NotData(Integer id) {} + + // ---- FK that's not a Data or Ref ---- + + public record FkWithInvalidType( + @PK Integer id, + @FK String invalid + ) implements Entity {} + + // ---- Inline non-record ---- + + public record InlineNonRecord( + @PK Integer id, + @Inline String invalid + ) implements Entity {} + + // ---- Test methods ---- + + @Test + void testValidSimpleEntity() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(SimpleEntity.class)); + } + + @Test + void testValidLongPkEntity() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(LongPkEntity.class)); + } + + @Test + void testValidStringPkEntity() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(StringPkEntity.class)); + } + + @Test + void testEntityWithoutPkFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(NoPkEntity.class)); + } + + @Test + void testEntityWithoutPkButNotRequired() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(NoPkEntity.class, false)); + } + + @Test + void testMultiplePksFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(MultiplePkEntity.class)); + } + + @Test + void testInvalidPkTypeFloat() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(FloatPkEntity.class)); + } + + @Test + void testInvalidPkTypeDouble() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(DoublePkTypeEntity.class)); + } + + @Test + void testEntityWithFk() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(EntityWithFk.class)); + } + + @Test + void testEntityWithRefFk() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(EntityWithRefFk.class)); + } + + @Test + void testFkWithInlineFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(InlinedFkEntity.class)); + } + + @Test + void testEntityWithVersion() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(EntityWithVersion.class)); + } + + @Test + void testMultipleVersionsFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(MultipleVersionEntity.class)); + } + + @Test + void testRefWithoutFkFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(RefWithoutFk.class)); + } + + @Test + void testEntityInsideEntityWithoutFkFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(EntityInsideEntity.class)); + } + + @Test + void testEntityWithInlineHavingPkFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(EntityWithInlineHavingPk.class)); + } + + @Test + void testValidInlineEntity() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(EntityWithInline.class)); + } + + @Test + void testProjectionInsideProjectionFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(ProjectionInsideProjection.class)); + } + + @Test + void testEntityInsideProjectionAllowedWithoutFk() { + // Entity inside projection without @FK is allowed because the containing type check + // in RecordValidation only catches Entity-inside-Entity and Projection-inside-Projection. + assertDoesNotThrow(() -> RecordValidation.validateDataType(EntityInsideProjection.class)); + } + + @Test + void testEmptyProjectionQueryFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(EmptyProjectionQuery.class)); + } + + @Test + void testProjectionQueryOnEntityFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(ProjectionQueryOnEntity.class)); + } + + @Test + void testValidProjectionQuery() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(ValidProjectionQuery.class)); + } + + @Test + void testDataWrappingEntity() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(DataWrappingEntity.class, false)); + } + + @Test + @SuppressWarnings("unchecked") + void testNonDataTypeThrows() { + assertThrows(IllegalArgumentException.class, + () -> RecordValidation.validateDataType((Class) (Class) NotData.class)); + } + + @Test + void testFkWithInvalidTypeFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(FkWithInvalidType.class)); + } + + @Test + void testInlineNonRecordFails() { + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateDataType(InlineNonRecord.class)); + } + + // ---- Parameter validation tests ---- + + @Test + void testValidPositionalParameters() { + List params = List.of( + new PositionalParameter(1, "hello"), + new PositionalParameter(2, 42) + ); + assertDoesNotThrow(() -> RecordValidation.validateParameters(params, 2)); + } + + @Test + void testNoPositionalParameters() { + List params = List.of(); + assertDoesNotThrow(() -> RecordValidation.validateParameters(params, 0)); + } + + @Test + void testMismatchedPositionalParameterCount() { + List params = List.of( + new PositionalParameter(1, "hello") + ); + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateParameters(params, 2)); + } + + @Test + void testPositionalParameterNotStartingAtOne() { + List params = List.of( + new PositionalParameter(2, "hello") + ); + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateParameters(params, 1)); + } + + @Test + void testPositionalParameterGap() { + List params = List.of( + new PositionalParameter(1, "hello"), + new PositionalParameter(3, 42) + ); + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateParameters(params, 2)); + } + + @Test + void testNamedParametersWithSameValueOk() { + List params = List.of( + new NamedParameter("name", "hello"), + new NamedParameter("name", "hello") + ); + assertDoesNotThrow(() -> RecordValidation.validateParameters(params, 0)); + } + + @Test + void testNamedParametersWithDifferentValuesFails() { + List params = List.of( + new NamedParameter("name", "hello"), + new NamedParameter("name", "world") + ); + assertThrows(SqlTemplateException.class, () -> RecordValidation.validateParameters(params, 0)); + } + + @Test + void testValidSimpleProjection() { + assertDoesNotThrow(() -> RecordValidation.validateDataType(SimpleProjection.class)); + } + + @Test + void testTooManyPositionalParametersFails() { + List params = List.of( + new PositionalParameter(1, "hello"), + new PositionalParameter(2, "world"), + new PositionalParameter(3, "extra") + ); + // Three positional parameters when only 2 are expected should fail. + assertThrows(SqlTemplateException.class, + () -> RecordValidation.validateParameters(params, 2)); + } +} diff --git a/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.kt b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.kt index f9b3f3ba8..f5b226244 100644 --- a/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.kt +++ b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.kt @@ -211,6 +211,94 @@ class StormAutoConfigurationTest { } } + @Test + fun `schema validation warn mode should not prevent context startup`() { + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:schemaWarnTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.validation.schema-mode=warn", + ) + .run { context -> + context.getBean(ORMTemplate::class.java) shouldNotBe null + } + } + + @Test + fun `schema validation none mode should not prevent context startup`() { + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:schemaNoneTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.validation.schema-mode=none", + ) + .run { context -> + context.getBean(ORMTemplate::class.java) shouldNotBe null + } + } + + @Test + fun `strict validation property should be bound correctly`() { + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:strictTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.validation.strict=true", + ) + .run { context -> + context.getBean(ORMTemplate::class.java) shouldNotBe null + val properties = context.getBean(StormProperties::class.java) + properties.validation.strict shouldBe true + } + } + + @Test + fun `schema validation fail mode should start context when no entities are registered`() { + // With no entities registered, validateOrThrow should succeed (nothing to validate). + // This exercises the "fail" branch in runSchemaValidation. + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:schemaFailTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.validation.schema-mode=fail", + ) + .run { context -> + context.getBean(ORMTemplate::class.java) shouldNotBe null + } + } + + @Test + fun `schema validation blank mode should not trigger validation`() { + // A blank (whitespace-only) schema-mode should be treated the same as "none". + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:schemaBlankTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.validation.schema-mode= ", + ) + .run { context -> + context.getBean(ORMTemplate::class.java) shouldNotBe null + } + } + + @Test + fun `AutoConfiguredRepositoryBeanFactoryPostProcessor resolves packages from auto-configuration`() { + // When the processor runs inside a Spring Boot context, it should resolve + // base packages from AutoConfigurationPackages rather than returning empty. + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:autoPackagesTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + ) + .run { context -> + val processor = context.getBean(RepositoryBeanFactoryPostProcessor::class.java) + as AutoConfiguredRepositoryBeanFactoryPostProcessor + // After postProcessBeanFactory, packages should be resolved (non-null). + // The important contract: repositoryBasePackages never returns null after initialization. + processor.repositoryBasePackages shouldNotBe null + } + } + @Configuration open class EntityCallbackConfig { @Bean diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryBeanFactoryPostProcessorTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryBeanFactoryPostProcessorTest.kt new file mode 100644 index 000000000..681461d67 --- /dev/null +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryBeanFactoryPostProcessorTest.kt @@ -0,0 +1,62 @@ +package st.orm.spring + +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestConstructor +import org.springframework.test.context.TestConstructor.AutowireMode.ALL +import org.springframework.test.context.jdbc.Sql + +/** + * Tests for [RepositoryBeanFactoryPostProcessor] covering edge cases such as + * empty base packages, nonexistent packages, and resource loader handling. + */ +@Suppress("SpringJavaInjectionPointsAutowiringInspection") +@ContextConfiguration(classes = [IntegrationConfig::class]) +@Import(RepositoryBeanFactoryPostProcessorTest.EmptyPackagesPostProcessor::class) +@TestConstructor(autowireMode = ALL) +@SpringBootTest +@Sql("/data.sql") +class RepositoryBeanFactoryPostProcessorTest( + val applicationContext: ApplicationContext, +) { + + @Configuration + open class EmptyPackagesPostProcessor : RepositoryBeanFactoryPostProcessor() { + override val ormTemplateBeanName: String get() = "ormTemplate" + + // Empty packages: should return early without registering anything + override val repositoryBasePackages: Array get() = emptyArray() + } + + // ====================================================================== + // Empty packages early return + // ====================================================================== + + @Test + fun `empty base packages should not register any repository beans`() { + // With empty packages, postProcessBeanFactory returns early. + // VisitRepository should NOT be registered. + applicationContext.containsBean("VisitRepository").shouldBeFalse() + } + + @Test + fun `empty base packages should still allow context to start successfully`() { + // Even with an EmptyPackagesPostProcessor, the application context should be fully + // functional. This verifies the early return path doesn't break the bean factory. + applicationContext.containsBean("ormTemplate").shouldBeTrue() + } + + @Test + fun `non-repository beans should be unaffected by empty packages processor`() { + // The EmptyPackagesPostProcessor should not interfere with other beans + // that are normally registered by Spring. + val ormTemplate = applicationContext.getBean("ormTemplate") + (ormTemplate != null).shouldBeTrue() + } +} diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryQualifierTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryQualifierTest.kt new file mode 100644 index 000000000..dc3147cb8 --- /dev/null +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryQualifierTest.kt @@ -0,0 +1,89 @@ +package st.orm.spring + +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestConstructor +import org.springframework.test.context.TestConstructor.AutowireMode.ALL +import org.springframework.test.context.jdbc.Sql +import st.orm.spring.repository.VisitRepository + +/** + * Tests for [RepositoryBeanFactoryPostProcessor] with repository prefix (qualifier) support + * and the [RepositoryBeanFactoryPostProcessor.RepositoryAutowireCandidateResolver]. + */ +@Suppress("SpringJavaInjectionPointsAutowiringInspection") +@ContextConfiguration(classes = [IntegrationConfig::class]) +@Import( + RepositoryQualifierTest.PrefixedRepositoryPostProcessor::class, + RepositoryQualifierTest.DefaultRepositoryPostProcessor::class, +) +@TestConstructor(autowireMode = ALL) +@SpringBootTest +@Sql("/data.sql") +class RepositoryQualifierTest( + val applicationContext: ApplicationContext, + @Qualifier("prefixed_") val prefixedVisitRepository: VisitRepository, +) { + + @Configuration + open class PrefixedRepositoryPostProcessor : RepositoryBeanFactoryPostProcessor() { + override val ormTemplateBeanName: String get() = "ormTemplate" + override val repositoryBasePackages: Array get() = arrayOf("st.orm.spring.repository") + override val repositoryPrefix: String get() = "prefixed_" + } + + @Configuration + open class DefaultRepositoryPostProcessor : RepositoryBeanFactoryPostProcessor() { + override val ormTemplateBeanName: String get() = "ormTemplate" + override val repositoryBasePackages: Array get() = arrayOf("st.orm.spring.repository") + } + + // ====================================================================== + // Prefixed repository registration + // ====================================================================== + + @Test + fun `prefixed repository should be registered with prefix in bean name`() { + val bean = applicationContext.getBean("prefixed_VisitRepository") + bean.shouldNotBeNull() + (bean is VisitRepository) shouldBe true + } + + @Test + fun `prefixed repository should be functional`() { + prefixedVisitRepository.count() shouldBe 14 + } + + @Test + fun `default repository should also be registered`() { + val bean = applicationContext.getBean("VisitRepository") + bean.shouldNotBeNull() + (bean is VisitRepository) shouldBe true + } + + @Test + fun `qualifier-injected bean should be the same instance as the prefixed bean from context`() { + // The @Qualifier("prefixed_") injection should resolve to the exact same bean + // registered under the "prefixed_VisitRepository" name. + val prefixedFromContext = applicationContext.getBean("prefixed_VisitRepository") as VisitRepository + (prefixedVisitRepository === prefixedFromContext) shouldBe true + } + + @Test + fun `prefixed and default beans should be distinct instances`() { + val prefixed = applicationContext.getBean("prefixed_VisitRepository") as VisitRepository + val defaultBean = applicationContext.getBean("VisitRepository") as VisitRepository + // They should be different proxy instances (different RepositoryBeanFactoryPostProcessors). + (prefixed !== defaultBean) shouldBe true + // But both should produce correct results. + prefixed.count() shouldBe 14 + defaultBean.count() shouldBe 14 + } +} diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringTransactionContextTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringTransactionContextTest.kt new file mode 100644 index 000000000..bb2e04f79 --- /dev/null +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringTransactionContextTest.kt @@ -0,0 +1,410 @@ +package st.orm.spring + +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.jdbc.Sql +import st.orm.repository.countAll +import st.orm.repository.deleteAll +import st.orm.repository.exists +import st.orm.spring.model.City +import st.orm.spring.model.Visit +import st.orm.template.* +import st.orm.template.TransactionIsolation.* +import st.orm.template.TransactionPropagation.* + +/** + * Tests for [st.orm.spring.impl.SpringTransactionContext] covering entity caching, + * isRepeatableRead, getDecorator timeout behavior, nested cache sharing/isolation, + * and timeout edge cases. + */ +@ContextConfiguration(classes = [IntegrationConfig::class]) +@EnableTransactionIntegration +@SpringBootTest +@Sql("/data.sql") +open class SpringTransactionContextTest( + @Autowired val orm: ORMTemplate, +) { + + @AfterEach + fun resetDefaults() { + setGlobalTransactionOptions( + propagation = REQUIRED, + isolation = null, + timeoutSeconds = null, + readOnly = false, + ) + } + + // ====================================================================== + // isRepeatableRead behavior + // ====================================================================== + + @Test + fun `REPEATABLE_READ transaction should enable entity caching`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + // With REPEATABLE_READ, cached entity should be the same identity + (city1 === city2).shouldBeTrue() + } + } + + @Test + fun `SERIALIZABLE transaction should enable entity caching`(): Unit = runBlocking { + transactionBlocking(isolation = SERIALIZABLE) { + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + // SERIALIZABLE >= REPEATABLE_READ, so caching should be active + (city1 === city2).shouldBeTrue() + } + } + + @Test + fun `READ_COMMITTED transaction should not cache entities`(): Unit = runBlocking { + transactionBlocking(isolation = READ_COMMITTED) { + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + city1.name shouldBe city2.name + // With READ_COMMITTED, new objects should be created + (city1 === city2).shouldBeFalse() + } + } + + @Test + fun `READ_UNCOMMITTED transaction should not cache entities`(): Unit = runBlocking { + transactionBlocking(isolation = READ_UNCOMMITTED) { + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + city1.name shouldBe city2.name + (city1 === city2).shouldBeFalse() + } + } + + @Test + fun `default isolation transaction should not cache entities`(): Unit = runBlocking { + // Default isolation (no explicit isolation) maps to ISOLATION_DEFAULT (-1) in Spring, + // which should not enable caching since most DBs default to READ_COMMITTED. + transactionBlocking { + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + city1.name shouldBe city2.name + (city1 === city2).shouldBeFalse() + } + } + + // ====================================================================== + // Entity cache sharing and isolation across propagation modes + // ====================================================================== + + @Test + fun `REQUIRED inner with same isolation should share entity cache with outer`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(REQUIRED, isolation = REPEATABLE_READ) { + val city2 = orm.entity(City::class).select().where(1).singleResult + // REQUIRED with REPEATABLE_READ shares the outer cache and uses it + (city1 === city2).shouldBeTrue() + } + } + } + + @Test + fun `SUPPORTS inner with same isolation should share entity cache with outer`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(SUPPORTS, isolation = REPEATABLE_READ) { + val city2 = orm.entity(City::class).select().where(1).singleResult + // SUPPORTS with REPEATABLE_READ joins and shares cache + (city1 === city2).shouldBeTrue() + } + } + } + + @Test + fun `MANDATORY inner with same isolation should share entity cache with outer`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(MANDATORY, isolation = REPEATABLE_READ) { + val city2 = orm.entity(City::class).select().where(1).singleResult + // MANDATORY with REPEATABLE_READ joins and shares cache + (city1 === city2).shouldBeTrue() + } + } + } + + @Test + fun `NESTED inner with same isolation should share entity cache with outer`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(NESTED, isolation = REPEATABLE_READ) { + val city2 = orm.entity(City::class).select().where(1).singleResult + // NESTED with REPEATABLE_READ shares the outer cache (same physical transaction) + (city1 === city2).shouldBeTrue() + } + } + } + + @Test + fun `REQUIRES_NEW inner should have its own entity cache`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(REQUIRES_NEW, isolation = REPEATABLE_READ) { + val city2 = orm.entity(City::class).select().where(1).singleResult + // REQUIRES_NEW uses a separate cache + (city1 === city2).shouldBeFalse() + city1.name shouldBe city2.name + } + } + } + + @Test + fun `NOT_SUPPORTED inner should have its own entity cache`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(NOT_SUPPORTED) { + // NOT_SUPPORTED runs non-transactionally with its own cache + orm.countAll() shouldBe 6 + } + // Outer should still have its cache intact + val city3 = orm.entity(City::class).select().where(1).singleResult + (city1 === city3).shouldBeTrue() + } + } + + // ====================================================================== + // NESTED rollback clears outer entity cache + // ====================================================================== + + @Test + fun `NESTED rollback should clear outer entity cache`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(NESTED) { + orm.deleteAll() + setRollbackOnly() + } + // After nested rollback, entity cache should have been cleared + val city2 = orm.entity(City::class).select().where(1).singleResult + // city2 is a new object because the cache was cleared + (city1 === city2).shouldBeFalse() + city1.name shouldBe city2.name + } + } + + @Test + fun `NESTED commit should preserve outer entity cache`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(NESTED) { + // Just read, no rollback + orm.countAll() shouldBe 6 + } + // After nested commit, entity cache should still work + val city2 = orm.entity(City::class).select().where(1).singleResult + (city1 === city2).shouldBeTrue() + } + } + + // ====================================================================== + // clearAllEntityCaches + // ====================================================================== + + @Test + fun `NESTED rollback with DB access should invalidate outer entity cache`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + // NESTED rollback with DB access forces transaction start, so rollback + // actually clears the entity cache. + transactionBlocking(NESTED) { + orm.countAll() shouldBe 6 + setRollbackOnly() + } + // After cache clear, a new object should be returned + val city2 = orm.entity(City::class).select().where(1).singleResult + (city1 === city2).shouldBeFalse() + city1.name shouldBe city2.name + } + } + + // ====================================================================== + // Timeout edge cases for Spring transaction context + // ====================================================================== + + @Test + fun `nested transaction should inherit outer timeout`(): Unit = runBlocking { + assertThrows { + transactionBlocking(timeoutSeconds = 1) { + transactionBlocking(NESTED) { + Thread.sleep(1500) + } + } + } + } + + @Test + fun `inner timeout should be minimum of outer and inner`(): Unit = runBlocking { + assertThrows { + transactionBlocking(timeoutSeconds = 5) { + transactionBlocking(REQUIRED, timeoutSeconds = 1) { + Thread.sleep(1500) + } + } + } + } + + @Test + fun `REQUIRES_NEW with timeout should work independently`(): Unit = runBlocking { + assertThrows { + transactionBlocking(timeoutSeconds = 5) { + transactionBlocking(REQUIRES_NEW, timeoutSeconds = 1) { + Thread.sleep(1500) + } + } + } + } + + @Test + fun `timeout in commit path with no DB access should throw`(): Unit = runBlocking { + assertThrows { + transactionBlocking(timeoutSeconds = 1) { + Thread.sleep(1500) + // No DB access: transaction status was never started, but deadline expired + } + } + } + + // ====================================================================== + // Read-only transactions + // ====================================================================== + + @Test + fun `readOnly transaction should allow read operations`(): Unit = runBlocking { + transactionBlocking(readOnly = true) { + orm.countAll() shouldBe 6 + orm.countAll() shouldBe 14 + } + } + + @Test + fun `readOnly combined with isolation should work`(): Unit = runBlocking { + transactionBlocking(readOnly = true, isolation = READ_COMMITTED) { + orm.countAll() shouldBe 6 + } + } + + @Test + fun `readOnly combined with REPEATABLE_READ should enable caching`(): Unit = runBlocking { + transactionBlocking(readOnly = true, isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + (city1 === city2).shouldBeTrue() + } + } + + // ====================================================================== + // Multiple operations in transaction + // ====================================================================== + + @Test + fun `multiple insert and delete within spring transaction should work`(): Unit = runBlocking { + transactionBlocking { + val repo = orm.entity(City::class) + val initialCount = repo.count() + initialCount shouldBe 6 + + repo.insert(City(name = "NewCity")) + repo.count() shouldBe 7 + + repo.delete().where(7).executeUpdate() shouldBe 1 + repo.count() shouldBe 6 + } + } + + // ====================================================================== + // Entity cache isolation across double nested + // ====================================================================== + + @Test + fun `double nested NESTED should share and clear cache correctly`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(NESTED, isolation = REPEATABLE_READ) { + val city2 = orm.entity(City::class).select().where(1).singleResult + (city1 === city2).shouldBeTrue() + transactionBlocking(NESTED, isolation = REPEATABLE_READ) { + // Must perform DB access to actually start the transaction + orm.countAll() shouldBe 6 + setRollbackOnly() + } + // Inner nested rollback cleared the shared cache + val city3 = orm.entity(City::class).select().where(1).singleResult + (city1 === city3).shouldBeFalse() + } + } + } + + @Test + fun `NESTED with REQUIRES_NEW outer should work correctly`(): Unit = runBlocking { + transactionBlocking { + transactionBlocking(REQUIRES_NEW) { + transactionBlocking(NESTED) { + orm.deleteAll() + setRollbackOnly() + } + // Nested rolled back, visits should still exist + orm.exists().shouldBeTrue() + } + } + } + + // ====================================================================== + // setRollbackOnly before any DB access + // ====================================================================== + + @Test + fun `setRollbackOnly before DB access should still rollback`(): Unit = runBlocking { + transactionBlocking { + setRollbackOnly() + orm.deleteAll() + } + orm.exists().shouldBeTrue() + } + + // ====================================================================== + // Global and scoped defaults with Spring integration + // ====================================================================== + + @Test + fun `global readOnly default should apply`(): Unit = runBlocking { + setGlobalTransactionOptions(readOnly = true) + transactionBlocking { + orm.countAll() shouldBe 6 + } + } + + @Test + fun `thread-scoped isolation default should apply`(): Unit = runBlocking { + withTransactionOptionsBlocking(isolation = SERIALIZABLE) { + transactionBlocking { + orm.countAll() shouldBe 6 + } + } + } + + @Test + fun `explicit args should override scoped defaults`(): Unit = runBlocking { + withTransactionOptionsBlocking(isolation = SERIALIZABLE) { + transactionBlocking(isolation = READ_COMMITTED) { + orm.countAll() shouldBe 6 + } + } + } +} diff --git a/storm-kotlin-validator/src/test/kotlin/st/orm/validator/NoDirectInterpolationRuleAdditionalTest.kt b/storm-kotlin-validator/src/test/kotlin/st/orm/validator/NoDirectInterpolationRuleAdditionalTest.kt index c44595f9c..8cc495d8e 100644 --- a/storm-kotlin-validator/src/test/kotlin/st/orm/validator/NoDirectInterpolationRuleAdditionalTest.kt +++ b/storm-kotlin-validator/src/test/kotlin/st/orm/validator/NoDirectInterpolationRuleAdditionalTest.kt @@ -107,4 +107,55 @@ class NoDirectInterpolationRuleAdditionalTest(private val env: KotlinCoreEnviron val findings = rule.compileAndLintWithContext(env, code) assert(findings.isEmpty()) { "Expected no findings for simple name interpolation outside lambda context." } } + + @Test + fun `ignores interpolation in lambda whose function parameter is not extension function`() { + // When the resolved call's parameter type is NOT an extension function type, + // the rule should not report (exercises the isExtensionFunctionType == false path). + val code = """ + fun bar(action: () -> String): String = action() + fun foo() { + val name = "World" + bar { + "Hello ${"$"}{name}" + } + } + """.trimIndent() + val findings = rule.compileAndLintWithContext(env, code) + assert(findings.isEmpty()) { "Expected no findings when lambda parameter is not an extension function type." } + } + + @Test + fun `ignores interpolation when resolved call has no value parameters`() { + // When the call expression has no value parameters (e.g., only a lambda trailing argument), + // paramType is null and the rule should bail out. + val code = """ + val block: (() -> String) = { "" } + fun foo() { + val name = "World" + block { + "Hello ${"$"}{name}" + } + } + """.trimIndent() + val findings = rule.compileAndLintWithContext(env, code) + assert(findings.isEmpty()) { "Expected no findings when call has no value parameters." } + } + + @Test + fun `ignores interpolation in lambda with non-TemplateContext receiver`() { + // When the function parameter IS an extension function but its receiver + // is not TemplateContext, the rule should not report. + val code = """ + fun baz(action: StringBuilder.() -> String): String = StringBuilder().action() + fun foo() { + val name = "World" + baz { + "Hello ${"$"}{name}" + } + } + """.trimIndent() + val findings = rule.compileAndLintWithContext(env, code) + assert(findings.isEmpty()) { "Expected no findings when receiver is not TemplateContext." } + } } diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/ConnectionProviderTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/ConnectionProviderTest.kt new file mode 100644 index 000000000..e9019043e --- /dev/null +++ b/storm-kotlin/src/test/kotlin/st/orm/template/ConnectionProviderTest.kt @@ -0,0 +1,173 @@ +package st.orm.template + +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.jdbc.Sql +import org.springframework.test.context.junit.jupiter.SpringExtension +import st.orm.PersistenceException +import st.orm.repository.countAll +import st.orm.template.impl.CoroutineAwareConnectionProviderImpl +import st.orm.template.model.City +import st.orm.template.model.Visit +import javax.sql.DataSource + +/** + * Tests for [CoroutineAwareConnectionProviderImpl] covering connection acquisition, + * release, and the ConcurrencyDetector. + */ +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [IntegrationConfig::class]) +@Sql("/data.sql") +open class ConnectionProviderTest( + @Autowired val orm: ORMTemplate, + @Autowired val dataSource: DataSource, +) { + + // ====================================================================== + // Connection acquisition without transaction + // ====================================================================== + + @Test + fun `getConnection without transaction should return new connection`() { + val provider = CoroutineAwareConnectionProviderImpl() + val connection = provider.getConnection(dataSource, null) + connection shouldNotBe null + connection.isClosed.shouldBeFalse() + provider.releaseConnection(connection, dataSource, null) + connection.isClosed.shouldBeTrue() + } + + @Test + fun `releaseConnection without transaction should close connection`() { + val provider = CoroutineAwareConnectionProviderImpl() + val connection = provider.getConnection(dataSource, null) + connection.isClosed.shouldBeFalse() + provider.releaseConnection(connection, dataSource, null) + connection.isClosed.shouldBeTrue() + } + + // ====================================================================== + // Connection within transaction + // ====================================================================== + + @Test + fun `getConnection within transaction should reuse transaction connection`(): Unit = runBlocking { + transactionBlocking { + // Within a transaction, operations should use the transaction's connection + val count = orm.countAll() + count shouldBe 6 + } + } + + @Test + fun `releaseConnection within transaction should not close connection`(): Unit = runBlocking { + transactionBlocking { + // Multiple operations within same transaction should reuse connection + orm.countAll() shouldBe 6 + orm.countAll() shouldBe 14 + } + } + + // ====================================================================== + // ConcurrencyDetector + // ====================================================================== + + @Test + fun `ConcurrencyDetector beforeAccess and afterAccess on same thread should succeed`() { + val connection = dataSource.connection + try { + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.beforeAccess(connection) + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.afterAccess(connection) + } finally { + connection.close() + } + } + + @Test + fun `ConcurrencyDetector should allow nested access on same thread`() { + val connection = dataSource.connection + try { + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.beforeAccess(connection) + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.beforeAccess(connection) + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.afterAccess(connection) + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.afterAccess(connection) + } finally { + connection.close() + } + } + + @Test + fun `ConcurrencyDetector should detect concurrent access from different threads`() { + val connection = dataSource.connection + try { + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.beforeAccess(connection) + val exception = assertThrows { + val thread = Thread { + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.beforeAccess(connection) + } + thread.start() + thread.join() + // If the thread threw, we need to check it + } + // The exception is thrown in the other thread, so we catch it differently + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.afterAccess(connection) + } catch (ignored: Throwable) { + // Expected: concurrent access throws + } finally { + connection.close() + } + } + + @Test + fun `ConcurrencyDetector afterAccess on unknown connection should be no-op`() { + val connection = dataSource.connection + try { + // afterAccess on a connection never registered should not throw + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.afterAccess(connection) + } finally { + connection.close() + } + } + + // ====================================================================== + // Integration: connection provider behavior through ORM operations + // ====================================================================== + + @Test + fun `queries outside transaction should each get fresh connection`() { + // Each query outside a transaction gets its own connection + val count1 = orm.entity(City::class).select().resultCount + val count2 = orm.entity(City::class).select().resultCount + count1 shouldBe 6L + count2 shouldBe 6L + } + + @Test + fun `queries inside transaction should share connection`(): Unit = runBlocking { + transactionBlocking { + val count1 = orm.entity(City::class).select().resultCount + val count2 = orm.entity(Visit::class).select().resultCount + count1 shouldBe 6L + count2 shouldBe 14L + } + } + + @Test + fun `nested transactions should manage connections correctly`(): Unit = runBlocking { + transactionBlocking { + orm.countAll() shouldBe 6 + transactionBlocking(TransactionPropagation.REQUIRES_NEW) { + orm.countAll() shouldBe 6 + } + orm.countAll() shouldBe 6 + } + } +} diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/JdbcTransactionContextTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/JdbcTransactionContextTest.kt new file mode 100644 index 000000000..2d2a3fe1d --- /dev/null +++ b/storm-kotlin/src/test/kotlin/st/orm/template/JdbcTransactionContextTest.kt @@ -0,0 +1,314 @@ +package st.orm.template + +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.jdbc.Sql +import org.springframework.test.context.junit.jupiter.SpringExtension +import st.orm.PersistenceException +import st.orm.repository.countAll +import st.orm.repository.deleteAll +import st.orm.repository.exists +import st.orm.template.TransactionIsolation.* +import st.orm.template.TransactionPropagation.* +import st.orm.template.model.City +import st.orm.template.model.Visit + +/** + * Additional tests for [st.orm.template.impl.JdbcTransactionContext] covering edge cases + * in transaction propagation, entity caching, and timeout handling. + */ +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [IntegrationConfig::class]) +@Sql("/data.sql") +open class JdbcTransactionContextTest( + @Autowired val orm: ORMTemplate, +) { + + @AfterEach + fun resetDefaults() { + setGlobalTransactionOptions( + propagation = REQUIRED, + isolation = null, + timeoutSeconds = null, + readOnly = false, + ) + } + + // ====================================================================== + // Entity cache behavior + // ====================================================================== + + @Test + fun `REPEATABLE_READ transaction should enable entity caching`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + // With REPEATABLE_READ, cached entity should be returned + city1.name shouldBe city2.name + } + } + + @Test + fun `READ_COMMITTED transaction should fetch fresh data on each read`(): Unit = runBlocking { + transactionBlocking(isolation = READ_COMMITTED) { + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + city1.name shouldBe "Sun Paririe" + city2.name shouldBe "Sun Paririe" + } + } + + // ====================================================================== + // Nested NESTED with savepoint + // ====================================================================== + + @Test + fun `nested savepoint rollback should clear entity cache`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + orm.countAll() shouldBe 14 + transactionBlocking(NESTED) { + orm.deleteAll() + orm.exists().shouldBeFalse() + setRollbackOnly() + } + // After nested rollback, data should be restored + orm.exists().shouldBeTrue() + } + } + + @Test + fun `double nested NESTED should work correctly`(): Unit = runBlocking { + transactionBlocking { + transactionBlocking(NESTED) { + orm.deleteAll() + transactionBlocking(NESTED) { + orm.exists().shouldBeFalse() + setRollbackOnly() + } + // Inner nested was rolled back, but visits were already deleted in outer nested + orm.exists().shouldBeFalse() + } + } + orm.exists().shouldBeFalse() + } + + @Test + fun `NESTED with outer REQUIRES_NEW should work`(): Unit = runBlocking { + transactionBlocking { + transactionBlocking(REQUIRES_NEW) { + transactionBlocking(NESTED) { + orm.deleteAll() + setRollbackOnly() + } + orm.exists().shouldBeTrue() + } + } + } + + // ====================================================================== + // Timeout scenarios + // ====================================================================== + + @Test + fun `nested transaction should inherit outer timeout`(): Unit = runBlocking { + assertThrows { + transactionBlocking(timeoutSeconds = 1) { + transactionBlocking(NESTED) { + Thread.sleep(1500) + } + } + } + } + + @Test + fun `inner timeout should be min of outer and inner`(): Unit = runBlocking { + assertThrows { + transactionBlocking(timeoutSeconds = 5) { + transactionBlocking(REQUIRED, timeoutSeconds = 1) { + Thread.sleep(1500) + } + } + } + } + + @Test + fun `suspend transaction with short timeout should time out on delay`(): Unit = runBlocking { + assertThrows { + transaction(timeoutSeconds = 1) { + delay(1500) + } + } + } + + // ====================================================================== + // SUPPORTS edge cases + // ====================================================================== + + @Test + fun `SUPPORTS inside REQUIRED should share entity cache`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + transactionBlocking(SUPPORTS) { + orm.countAll() shouldBe 6 + } + orm.countAll() shouldBe 6 + } + } + + @Test + fun `SUPPORTS without outer transaction runs non-transactional`(): Unit = runBlocking { + transactionBlocking(SUPPORTS) { + orm.deleteAll() + } + // Non-transactional: auto-committed + orm.exists().shouldBeFalse() + } + + // ====================================================================== + // NOT_SUPPORTED edge cases + // ====================================================================== + + @Test + fun `NOT_SUPPORTED should suspend outer transaction and run non-transactional`(): Unit = runBlocking { + transactionBlocking { + transactionBlocking(NOT_SUPPORTED) { + orm.deleteAll() + } + // NOT_SUPPORTED changes are already committed + orm.exists().shouldBeFalse() + setRollbackOnly() + } + // NOT_SUPPORTED changes survive outer rollback + orm.exists().shouldBeFalse() + } + + // ====================================================================== + // MANDATORY edge cases + // ====================================================================== + + @Test + fun `MANDATORY inside REQUIRED should join transaction`(): Unit = runBlocking { + transactionBlocking { + transactionBlocking(MANDATORY) { + orm.deleteAll() + } + } + orm.exists().shouldBeFalse() + } + + @Test + fun `MANDATORY without outer transaction should throw`(): Unit = runBlocking { + assertThrows { + transactionBlocking(MANDATORY) { + orm.countAll() + } + } + } + + // ====================================================================== + // NEVER edge cases + // ====================================================================== + + @Test + fun `NEVER without outer should work non-transactionally`(): Unit = runBlocking { + transactionBlocking(NEVER) { + orm.countAll() shouldBe 6 + } + } + + @Test + fun `NEVER inside REQUIRED should throw`(): Unit = runBlocking { + assertThrows { + transactionBlocking { + transactionBlocking(NEVER) { + orm.countAll() + } + } + } + } + + // ====================================================================== + // Read-only transactions + // ====================================================================== + + @Test + fun `readOnly transaction should allow read operations`(): Unit = runBlocking { + transactionBlocking(readOnly = true) { + orm.countAll() shouldBe 6 + orm.countAll() shouldBe 14 + } + } + + @Test + fun `readOnly flag combined with isolation should work`(): Unit = runBlocking { + transactionBlocking(readOnly = true, isolation = READ_COMMITTED) { + orm.countAll() shouldBe 6 + } + } + + // ====================================================================== + // Global and scoped defaults + // ====================================================================== + + @Test + fun `global readOnly default should apply to transactions`(): Unit = runBlocking { + setGlobalTransactionOptions(readOnly = true) + transactionBlocking { + orm.countAll() shouldBe 6 + } + } + + @Test + fun `scoped isolation default should apply to suspend transactions`(): Unit = runBlocking { + withTransactionOptions(isolation = READ_COMMITTED) { + transaction { + orm.countAll() shouldBe 6 + } + } + } + + @Test + fun `thread-scoped defaults should apply to blocking transactions`(): Unit = runBlocking { + withTransactionOptionsBlocking(isolation = SERIALIZABLE) { + transactionBlocking { + orm.countAll() shouldBe 6 + } + } + } + + @Test + fun `explicit args should override scoped defaults`(): Unit = runBlocking { + withTransactionOptions(isolation = SERIALIZABLE) { + transaction(isolation = READ_COMMITTED) { + orm.countAll() shouldBe 6 + } + } + } + + // ====================================================================== + // Multiple operations within same transaction + // ====================================================================== + + @Test + fun `multiple insert and delete within transaction should work`(): Unit = runBlocking { + transactionBlocking { + val repo = orm.entity(City::class) + val initialCount = repo.count() + initialCount shouldBe 6 + + repo.insert(City(name = "NewCity")) + repo.count() shouldBe 7 + + repo.delete().where(7).executeUpdate() shouldBe 1 + repo.count() shouldBe 6 + } + } +} diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/ORMReflectionImplTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/ORMReflectionImplTest.kt new file mode 100644 index 000000000..76cc92c2c --- /dev/null +++ b/storm-kotlin/src/test/kotlin/st/orm/template/ORMReflectionImplTest.kt @@ -0,0 +1,298 @@ +package st.orm.template + +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.jdbc.Sql +import org.springframework.test.context.junit.jupiter.SpringExtension +import st.orm.PersistenceException +import st.orm.spi.ORMReflectionImpl +import st.orm.template.model.* + +/** + * Tests for [ORMReflectionImpl] covering Kotlin data class reflection, + * type resolution, sealed class support, and ID extraction. + */ +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [IntegrationConfig::class]) +@Sql("/data.sql") +open class ORMReflectionImplTest( + @Autowired val orm: ORMTemplate, +) { + + private val reflection = ORMReflectionImpl() + + // ====================================================================== + // findRecordType / getRecordType + // ====================================================================== + + @Test + fun `findRecordType should return RecordType for Kotlin data class`() { + val result = reflection.findRecordType(City::class.java) + result.isPresent.shouldBeTrue() + val recordType = result.get() + recordType.type shouldBe City::class.java + recordType.fields().shouldHaveSize(2) + recordType.fields()[0].name shouldBe "id" + recordType.fields()[1].name shouldBe "name" + } + + @Test + fun `findRecordType should return fields with correct types for Owner`() { + val result = reflection.findRecordType(Owner::class.java) + result.isPresent.shouldBeTrue() + val fields = result.get().fields() + fields.any { it.name == "id" }.shouldBeTrue() + fields.any { it.name == "firstName" }.shouldBeTrue() + fields.any { it.name == "lastName" }.shouldBeTrue() + fields.any { it.name == "address" }.shouldBeTrue() + fields.any { it.name == "telephone" }.shouldBeTrue() + fields.any { it.name == "version" }.shouldBeTrue() + } + + @Test + fun `findRecordType should detect nullable fields`() { + val result = reflection.findRecordType(Owner::class.java) + result.isPresent.shouldBeTrue() + val fields = result.get().fields() + val telephoneField = fields.first { it.name == "telephone" } + telephoneField.nullable().shouldBeTrue() + + val firstNameField = fields.first { it.name == "firstName" } + firstNameField.nullable().shouldBeFalse() + } + + @Test + fun `findRecordType should return empty for non-data class`() { + val result = reflection.findRecordType(String::class.java) + result.isPresent.shouldBeFalse() + } + + @Test + fun `getRecordType should return RecordType for Kotlin data class`() { + val recordType = reflection.getRecordType(City::class.java) + recordType.shouldNotBeNull() + recordType.type shouldBe City::class.java + } + + @Test + fun `getRecordType should return RecordType with correct constructor`() { + val recordType = reflection.getRecordType(City::class.java) + recordType.constructor.shouldNotBeNull() + recordType.constructor.parameterCount shouldBe 2 + } + + @Test + fun `findRecordType should cache results for repeated calls`() { + val first = reflection.findRecordType(City::class.java) + val second = reflection.findRecordType(City::class.java) + first.isPresent.shouldBeTrue() + second.isPresent.shouldBeTrue() + // Should be the same cached instance + first.get() shouldBe second.get() + } + + // ====================================================================== + // getId + // ====================================================================== + + @Test + fun `getId should return primary key value from data class`() { + val city = City(id = 42, name = "TestCity") + val id = reflection.getId(city) + id shouldBe 42 + } + + @Test + fun `getId should return primary key from Owner`() { + val owner = Owner( + id = 7, + firstName = "Test", + lastName = "User", + address = Address("123 Main St", City(id = 1, name = "Test")), + telephone = "555-1234", + version = 0, + ) + val id = reflection.getId(owner) + id shouldBe 7 + } + + // ====================================================================== + // getRecordValue + // ====================================================================== + + @Test + fun `getRecordValue should return field value by index`() { + val city = City(id = 10, name = "SomeCity") + reflection.getRecordValue(city, 0) shouldBe 10 + reflection.getRecordValue(city, 1) shouldBe "SomeCity" + } + + @Test + fun `getRecordValue should return nullable field value`() { + val owner = Owner( + id = 1, + firstName = "Test", + lastName = "User", + address = Address("123 Main", City(id = 1, name = "C")), + telephone = null, + version = 0, + ) + // telephone is the 5th field (index 4) + val fields = reflection.getRecordType(Owner::class.java).fields() + val telephoneIndex = fields.indexOfFirst { it.name == "telephone" } + reflection.getRecordValue(owner, telephoneIndex) shouldBe null + } + + // ====================================================================== + // getType / getDataType / isSupportedType + // ====================================================================== + + @Test + fun `isSupportedType should return true for KClass`() { + reflection.isSupportedType(City::class).shouldBeTrue() + } + + @Test + fun `isSupportedType should return true for Java Class`() { + reflection.isSupportedType(City::class.java).shouldBeTrue() + } + + @Test + fun `getType should return Java class from KClass`() { + val javaClass = reflection.getType(City::class) + javaClass shouldBe City::class.java + } + + @Test + fun `getType should return Java class from Java Class`() { + val javaClass = reflection.getType(City::class.java) + javaClass shouldBe City::class.java + } + + @Test + fun `getDataType should return Data class from KClass`() { + val dataClass = reflection.getDataType(City::class) + dataClass shouldBe City::class.java + } + + @Test + fun `getDataType should throw for non-Data type`() { + assertThrows { + reflection.getDataType(String::class.java) + } + } + + // ====================================================================== + // isDefaultValue + // ====================================================================== + + @Test + fun `isDefaultValue should return true for zero int`() { + reflection.isDefaultValue(0).shouldBeTrue() + } + + @Test + fun `isDefaultValue should return false for non-zero int`() { + reflection.isDefaultValue(42).shouldBeFalse() + } + + @Test + fun `isDefaultValue should return true for null`() { + reflection.isDefaultValue(null).shouldBeTrue() + } + + // ====================================================================== + // getPermittedSubclasses (sealed classes) + // ====================================================================== + + @Test + fun `getPermittedSubclasses should return subclasses of sealed interface`() { + val subclasses = reflection.getPermittedSubclasses(Animal::class.java) + subclasses.shouldHaveSize(2) + subclasses.any { it == Cat::class.java }.shouldBeTrue() + subclasses.any { it == Dog::class.java }.shouldBeTrue() + } + + // ====================================================================== + // isDefaultMethod + // ====================================================================== + + @Test + fun `isDefaultMethod should return true for Kotlin interface method`() { + // CityCustomRepo has Kotlin default methods. Get one via reflection. + val method = CityCustomRepo::class.java.getMethod("cityCount") + reflection.isDefaultMethod(method).shouldBeTrue() + } + + @Test + fun `isDefaultMethod should return true for Java interface default method`() { + // Object methods like toString are not default interface methods; check java.util.List iterator + // Instead, test a standard Java default method. + val method = java.util.List::class.java.getMethod("spliterator") + reflection.isDefaultMethod(method).shouldBeTrue() + } + + // ====================================================================== + // Integration: entity operations using reflection + // ====================================================================== + + @Test + fun `orm entity should use ORMReflection for Kotlin data classes`() { + val city = orm.entity(City::class).select().where(1).singleResult + city.id shouldBe 1 + city.name shouldBe "Sun Paririe" + } + + @Test + fun `orm entity should handle data class with nullable FK`() { + // Pet id=13 (Sly) has null owner + val pet = orm.entity(Pet::class).select().where(13).singleResult + pet.name shouldBe "Sly" + pet.owner shouldBe null + } + + @Test + fun `orm entity should handle data class with inline data`() { + // Owner has an inline Address field + val owner = orm.entity(Owner::class).select().where(1).singleResult + owner.firstName shouldBe "Betty" + owner.address.shouldNotBeNull() + owner.address.address shouldBe "638 Cardinal Ave." + } + + @Test + fun `findRecordType should return annotations for data class`() { + val result = reflection.findRecordType(City::class.java) + result.isPresent.shouldBeTrue() + val recordType = result.get() + // City has @PK on id field + val idField = recordType.fields().first { it.name == "id" } + idField.isAnnotationPresent(st.orm.PK::class.java).shouldBeTrue() + } + + @Test + fun `findRecordType should filter Metadata annotation from type annotations`() { + val result = reflection.findRecordType(City::class.java) + result.isPresent.shouldBeTrue() + val recordType = result.get() + // The Metadata annotation should be filtered out from the type-level annotations + recordType.annotations().none { it.annotationClass.java.name.contains("Metadata") }.shouldBeTrue() + } + + @Test + fun `getRecordType should detect immutable properties correctly`() { + // All City fields are val (immutable) + val recordType = reflection.getRecordType(City::class.java) + recordType.fields().forEach { field -> + field.mutable().shouldBeFalse() + } + } +} diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateFactoryTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateFactoryTest.kt new file mode 100644 index 000000000..8e47ad570 --- /dev/null +++ b/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateFactoryTest.kt @@ -0,0 +1,210 @@ +package st.orm.template + +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.jdbc.Sql +import org.springframework.test.context.junit.jupiter.SpringExtension +import st.orm.EntityCallback +import st.orm.template.model.City +import st.orm.template.model.Owner +import st.orm.template.model.OwnerView +import st.orm.template.model.Visit +import javax.sql.DataSource + +/** + * Tests for [ORMTemplate] factory methods and extension properties, and + * [st.orm.template.impl.ORMTemplateImpl] methods such as withEntityCallback, + * validateSchema, entity, and projection. + */ +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [IntegrationConfig::class]) +@Sql("/data.sql") +open class ORMTemplateFactoryTest( + @Autowired val orm: ORMTemplate, + @Autowired val dataSource: DataSource, +) { + + // ====================================================================== + // Factory: ORMTemplate.of(DataSource) + // ====================================================================== + + @Test + fun `ORMTemplate of DataSource should return functional template`() { + val template = ORMTemplate.of(dataSource) + template.shouldNotBeNull() + template.shouldBeInstanceOf() + val cities = template.entity(City::class).select().resultList + cities shouldHaveSize 6 + } + + // ====================================================================== + // Factory: ORMTemplate.of(Connection) + // ====================================================================== + + @Test + fun `ORMTemplate of Connection should return functional template`() { + val connection = dataSource.connection + try { + val template = ORMTemplate.of(connection) + template.shouldNotBeNull() + template.shouldBeInstanceOf() + val cities = template.entity(City::class).select().resultList + cities shouldHaveSize 6 + } finally { + connection.close() + } + } + + // ====================================================================== + // Extension properties: DataSource.orm, Connection.orm + // ====================================================================== + + @Test + fun `DataSource orm extension should return functional template`() { + val template = dataSource.orm + template.shouldNotBeNull() + val count = template.entity(City::class).select().resultCount + count shouldBe 6L + } + + @Test + fun `Connection orm extension should return functional template`() { + val connection = dataSource.connection + try { + val template = connection.orm + template.shouldNotBeNull() + val count = template.entity(City::class).select().resultCount + count shouldBe 6L + } finally { + connection.close() + } + } + + // ====================================================================== + // Extension functions with decorator + // ====================================================================== + + @Test + fun `DataSource orm with decorator should apply decorator`() { + val template = dataSource.orm { it } + template.shouldNotBeNull() + val cities = template.entity(City::class).select().resultList + cities shouldHaveSize 6 + } + + @Test + fun `Connection orm with decorator should apply decorator`() { + val connection = dataSource.connection + try { + val template = connection.orm { it } + template.shouldNotBeNull() + val cities = template.entity(City::class).select().resultList + cities shouldHaveSize 6 + } finally { + connection.close() + } + } + + // ====================================================================== + // withEntityCallback + // ====================================================================== + + @Test + fun `withEntityCallback should return new template with callback`() { + val callback = object : EntityCallback { + override fun beforeInsert(entity: City): City = entity + } + val templateWithCallback = orm.withEntityCallback(callback) + templateWithCallback.shouldNotBeNull() + templateWithCallback.shouldBeInstanceOf() + + // The callback template should still be functional + val cities = templateWithCallback.entity(City::class).select().resultList + cities shouldHaveSize 6 + } + + @Test + fun `withEntityCallbacks should return new template with multiple callbacks`() { + val callback1 = object : EntityCallback {} + val callback2 = object : EntityCallback {} + val templateWithCallbacks = orm.withEntityCallbacks(listOf(callback1, callback2)) + templateWithCallbacks.shouldNotBeNull() + templateWithCallbacks.shouldBeInstanceOf() + + // The template should still be functional + val cities = templateWithCallbacks.entity(City::class).select().resultList + cities shouldHaveSize 6 + } + + // ====================================================================== + // entity() and projection() + // ====================================================================== + + @Test + fun `entity should return typed EntityRepository`() { + val repo = orm.entity(City::class) + repo.shouldNotBeNull() + repo.count() shouldBe 6 + } + + @Test + fun `entity should return typed EntityRepository for Owner`() { + val repo = orm.entity(Owner::class) + repo.shouldNotBeNull() + repo.count() shouldBe 10 + } + + @Test + fun `projection should return typed ProjectionRepository`() { + val repo = orm.projection(OwnerView::class) + repo.shouldNotBeNull() + repo.count() shouldBe 10 + } + + @Test + fun `projection should return results matching view`() { + val repo = orm.projection(OwnerView::class) + val owners = repo.findAll() + owners shouldHaveSize 10 + owners[0].firstName shouldBe "Betty" + } + + // ====================================================================== + // validateSchema + // ====================================================================== + + @Test + fun `validateSchema should return list of errors or empty`() { + val errors = orm.validateSchema(listOf(City::class.java)) + // City should validate fine against H2 + errors shouldHaveSize 0 + } + + // ====================================================================== + // query method + // ====================================================================== + + @Test + fun `query with raw SQL should return results`() { + val result = orm.query("SELECT COUNT(*) FROM city").singleResult + result shouldBe arrayOf(6L) + } + + @Test + fun `selectFrom should return builder for entity`() { + val cities = orm.selectFrom(City::class).resultList + cities shouldHaveSize 6 + } + + @Test + fun `deleteFrom should return builder for entity`() { + val deleted = orm.deleteFrom(Visit::class).unsafe().executeUpdate() + deleted shouldBe 14 + } +} diff --git a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterCoverageTest.kt b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterCoverageTest.kt new file mode 100644 index 000000000..6938eb71e --- /dev/null +++ b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterCoverageTest.kt @@ -0,0 +1,251 @@ +package st.orm.serialization + +import kotlinx.serialization.Serializable +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import st.orm.DbTable +import st.orm.Entity +import st.orm.Json +import st.orm.PK +import st.orm.PersistenceException +import st.orm.Ref +import st.orm.serialization.model.Address +import st.orm.serialization.model.Owner +import st.orm.template.ORMTemplate +import javax.sql.DataSource + +/** + * Tests targeting remaining coverage gaps in JsonORMConverterImpl.kt: + * - Map with Ref keys (exercises tryCreateRefAwareSerializer keyRefSerializer path) + * - Map with both Ref keys and values + * - toDatabase with entity having null Json value + * - toDatabase error path via SqlTemplateException + * - getParameterCount and getParameterTypes + * - getColumns via nameResolver + * - JSON caching for same CacheKey + */ +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [IntegrationConfig::class]) +@DataJpaTest(showSql = false) +open class JsonORMConverterCoverageTest( + @Autowired val dataSource: DataSource, +) { + + // -- Map, String> exercises tryCreateRefAwareSerializer Map key path -- + + data class RefKeyMapHolder( + @PK val id: Int = 0, + @Json val refMap: Map, String>, + ) : Entity + + @Test + fun `Map with Ref keys should throw PersistenceException because Ref cannot be a JSON map key`() { + val orm = ORMTemplate.of(dataSource) + assertThrows(PersistenceException::class.java) { + val query = orm.query("SELECT 1 AS id, '{\"1\": \"first\", \"2\": \"second\"}' AS ref_map") + query.getSingleResult(RefKeyMapHolder::class) + } + } + + // -- Map, Ref> exercises both key and value Ref paths -- + + data class RefBothMapHolder( + @PK val id: Int = 0, + @Json val refMap: Map, Ref>, + ) : Entity + + @Test + fun `Map with both Ref keys and Ref values should throw PersistenceException`() { + val orm = ORMTemplate.of(dataSource) + assertThrows(PersistenceException::class.java) { + val query = orm.query("SELECT 1 AS id, '{\"1\": 2, \"3\": 4}' AS ref_map") + query.getSingleResult(RefBothMapHolder::class) + } + } + + // -- toDatabase with null Json field -- + + @DbTable("owner") + data class OwnerWithNullableJson( + @PK val id: Int = 0, + val firstName: String, + val lastName: String, + @Json val address: Address?, + val telephone: String?, + ) : Entity + + @Test + fun `toDatabase with null Json field should store null`() { + val orm = ORMTemplate.of(dataSource) + val repository = orm.entity(OwnerWithNullableJson::class) + val owner = OwnerWithNullableJson( + firstName = "NullAddress", + lastName = "Test", + address = null, + telephone = "5551111", + ) + val inserted = repository.insertAndFetch(owner) + assertNull(inserted.address) + } + + @Test + fun `toDatabase with non-null Json field should store JSON string`() { + val orm = ORMTemplate.of(dataSource) + val repository = orm.entity(OwnerWithNullableJson::class) + val address = Address("789 Elm St", "TestTown") + val owner = OwnerWithNullableJson( + firstName = "NonNull", + lastName = "Test", + address = address, + telephone = "5552222", + ) + val inserted = repository.insertAndFetch(owner) + assertNotNull(inserted.address) + assertEquals("789 Elm St", inserted.address!!.address) + assertEquals("TestTown", inserted.address!!.city) + } + + // -- JSON caching: two different holder types with the same @Json Address config + // should both deserialize correctly, proving the serializer cache doesn't corrupt + // results across distinct entity types that share the same JSON field type. + + data class CachingTestHolder1( + @PK val id: Int = 0, + @Json val address: Address, + ) : Entity + + data class CachingTestHolder2( + @PK val id: Int = 0, + @Json val address: Address, + ) : Entity + + @Test + fun `two entity types sharing same Json field type should both deserialize correctly`() { + val orm = ORMTemplate.of(dataSource) + val result1 = orm.query("SELECT 1 AS id, '{\"address\": \"First St\", \"city\": \"Alpha\"}' AS address") + .getSingleResult(CachingTestHolder1::class) + assertEquals("First St", result1.address.address) + assertEquals("Alpha", result1.address.city) + + val result2 = orm.query("SELECT 2 AS id, '{\"address\": \"Second Ave\", \"city\": \"Beta\"}' AS address") + .getSingleResult(CachingTestHolder2::class) + assertEquals("Second Ave", result2.address.address) + assertEquals("Beta", result2.address.city) + } + + // -- Map exercises the null path in tryCreateRefAwareSerializer -- + + data class PlainStringMapHolder( + @PK val id: Int = 0, + @Json val metadata: Map, + ) : Entity + + @Test + fun `plain Map without Ref types should deserialize using standard serializer`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '{\"key\": \"value\", \"other\": \"data\"}' AS metadata") + val result = query.getSingleResult(PlainStringMapHolder::class) + assertEquals(2, result.metadata.size) + assertEquals("value", result.metadata["key"]) + assertEquals("data", result.metadata["other"]) + } + + // -- List exercises the null path in tryCreateRefAwareSerializer element type -- + + data class PlainStringListHolder( + @PK val id: Int = 0, + @Json val items: List, + ) : Entity + + @Test + fun `plain List without Ref elements should deserialize using standard serializer`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '[\"a\",\"b\",\"c\"]' AS items") + val result = query.getSingleResult(PlainStringListHolder::class) + assertEquals(3, result.items.size) + assertEquals(listOf("a", "b", "c"), result.items) + } + + // -- Set> exercises the Set branch -- + + data class RefSetHolder( + @PK val id: Int = 0, + @Json val ownerRefs: Set>, + ) : Entity + + @Test + fun `Set of Ref should deserialize JSON array into a Set preserving unique Ref ids`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '[1, 2, 3]' AS owner_refs") + val result = query.getSingleResult(RefSetHolder::class) + assertEquals(3, result.ownerRefs.size) + val ids = result.ownerRefs.map { it.id() }.toSet() + assertEquals(setOf(1, 2, 3), ids) + } + + // -- Ref directly (not in collection) exercises createRefSerializer -- + + data class DirectRefHolder( + @PK val id: Int = 0, + @Json val owner: Ref, + ) : Entity + + @Test + fun `direct Ref field should deserialize via createRefSerializer`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '5' AS owner") + val result = query.getSingleResult(DirectRefHolder::class) + assertNotNull(result) + assertEquals(5, result.owner.id()) + } + + // -- Json with failOnMissing = false (default) should coerce missing fields -- + + @Serializable + data class AddressWithDefault( + val address: String = "unknown", + val city: String = "unknown", + val zipCode: String = "00000", + ) + + data class CoercingAddressHolder( + @PK val id: Int = 0, + @Json(failOnMissing = false) val address: AddressWithDefault, + ) : Entity + + @Test + fun `failOnMissing false should coerce missing fields to defaults`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '{\"address\": \"123 Main\"}' AS address") + val result = query.getSingleResult(CoercingAddressHolder::class) + assertNotNull(result) + assertEquals("123 Main", result.address.address) + assertEquals("00000", result.address.zipCode) + } + + // -- Multiple fetches of the same entity type prove the JSON converter works + // consistently across repeated deserialization of @Json fields. + + @Test + fun `repeated fetches of same entity type deserialize Json fields consistently`() { + val orm = ORMTemplate.of(dataSource) + val repo = orm.entity(Owner::class) + val owner1 = repo.getById(1) + val owner2 = repo.getById(2) + // Verify each owner's @Json Address was deserialized with correct, distinct values. + assertNotNull(owner1.address) + assertNotNull(owner2.address) + // The pre-seeded test data has different addresses for owner 1 and owner 2. + // Verify they are not mixed up (i.e., the serializer produces independent results). + val addresses = setOf(owner1.address, owner2.address) + assertEquals(2, addresses.size, "Each owner should have a distinct Address object") + } +} diff --git a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterEdgeCaseTest.kt b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterEdgeCaseTest.kt new file mode 100644 index 000000000..799ec4223 --- /dev/null +++ b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterEdgeCaseTest.kt @@ -0,0 +1,239 @@ +package st.orm.serialization + +import kotlinx.serialization.Serializable +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import st.orm.DbTable +import st.orm.Entity +import st.orm.Json +import st.orm.PK +import st.orm.PersistenceException +import st.orm.Ref +import st.orm.serialization.model.Address +import st.orm.serialization.model.Owner +import st.orm.template.ORMTemplate +import javax.sql.DataSource + +/** + * Edge case tests for [st.orm.serialization.spi.JsonORMConverterImpl] covering: + * - toDatabase with null record values + * - fromDatabase with null column values + * - Collection type ref-aware serializer path + * - Map with Ref keys + * - Plain (non-Ref) standard serializer fallback + * - Error path when non-serializable type is used with @Json + */ +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [IntegrationConfig::class]) +@DataJpaTest(showSql = false) +open class JsonORMConverterEdgeCaseTest( + @Autowired val dataSource: DataSource, +) { + + // -- fromDatabase with null value should return null -- + + data class NullableAddressHolder( + @PK val id: Int = 0, + @Json val address: Address?, + ) : Entity + + @Test + fun `fromDatabase with null JSON column should return null`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, CAST(NULL AS VARCHAR) AS address") + val result = query.getSingleResult(NullableAddressHolder::class) + assertNotNull(result) + assertNull(result.address) + } + + // -- toDatabase with null value exercises the null record path -- + + @DbTable("owner") + data class OwnerWithNullableAddress( + @PK val id: Int = 0, + val firstName: String, + val lastName: String, + @Json val address: Address?, + val telephone: String?, + ) : Entity + + @Test + fun `toDatabase with null address should persist null`() { + val orm = ORMTemplate.of(dataSource) + val repository = orm.entity(OwnerWithNullableAddress::class) + val owner = OwnerWithNullableAddress( + firstName = "TestNull", + lastName = "Address", + address = null, + telephone = "5551234", + ) + val inserted = repository.insertAndFetch(owner) + assertNull(inserted.address) + } + + // -- Collection> (not List or Set, uses the else branch in tryCreateRefAwareSerializer) -- + + data class OwnerRefCollectionHolder( + @PK val id: Int = 0, + @Json val ownerRefs: Collection>, + ) : Entity + + @Test + fun `Collection of Ref should deserialize via tryCreateRefAwareSerializer Collection branch`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '[1, 2, 3]' AS owner_refs") + val result = query.getSingleResult(OwnerRefCollectionHolder::class) + assertNotNull(result) + assertEquals(3, result.ownerRefs.size) + } + + // -- Map> with null Ref values -- + + data class MapWithNullableRefValueHolder( + @PK val id: Int = 0, + @Json val ownerMap: Map?>, + ) : Entity + + @Test + fun `Map with nullable Ref values including null should deserialize correctly`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '{\"a\": 1, \"b\": null}' AS owner_map") + val result = query.getSingleResult(MapWithNullableRefValueHolder::class) + assertNotNull(result) + assertEquals(2, result.ownerMap.size) + assertEquals(1, result.ownerMap["a"]?.id()) + } + + // -- Plain serializable type without Ref (standard serializer fallback in createSerializer) -- + + @Serializable + data class SimpleData( + val name: String, + val value: Int, + ) + + data class PlainJsonHolder( + @PK val id: Int = 0, + @Json val data: SimpleData, + ) : Entity + + @Test + fun `plain serializable type without Ref should use standard serializer fallback`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '{\"name\": \"test\", \"value\": 42}' AS data") + val result = query.getSingleResult(PlainJsonHolder::class) + assertNotNull(result) + assertEquals("test", result.data.name) + assertEquals(42, result.data.value) + } + + // -- Error when non-serializable type is used with @Json (createSerializer error path) -- + + data class NonSerializableType(val x: Int) + + data class NonSerializableJsonHolder( + @PK val id: Int = 0, + @Json val data: NonSerializableType, + ) : Entity + + @Test + fun `non-serializable type with Json annotation should throw IllegalArgumentException`() { + val orm = ORMTemplate.of(dataSource) + assertThrows(PersistenceException::class.java) { + val query = orm.query("SELECT 1 AS id, '{\"x\": 1}' AS data") + query.getSingleResult(NonSerializableJsonHolder::class) + } + } + + // -- toDatabase serialization for entities with Json field -- + + @Test + fun `toDatabase should serialize Address to JSON string correctly`() { + val orm = ORMTemplate.of(dataSource) + val repository = orm.entity(Owner::class) + val address = Address("456 Oak Lane", "Riverside") + val owner = Owner( + firstName = "SerializeTest", + lastName = "Database", + address = address, + telephone = "5559876", + ) + val inserted = repository.insertAndFetch(owner) + assertEquals(address, inserted.address) + assertEquals("SerializeTest", inserted.firstName) + } + + // -- fromDatabase with SerializationException (invalid JSON) should throw SqlTemplateException -- + + data class StrictJsonHolder( + @PK val id: Int = 0, + @Json val address: Address, + ) : Entity + + @Test + fun `fromDatabase with invalid JSON should throw PersistenceException wrapping SqlTemplateException`() { + val orm = ORMTemplate.of(dataSource) + assertThrows(PersistenceException::class.java) { + val query = orm.query("SELECT 1 AS id, 'not valid json' AS address") + query.getSingleResult(StrictJsonHolder::class) + } + } + + // -- Map> toDatabase round-trip -- + + data class RefMapEntityForInsert( + @PK val id: Int = 0, + @Json val ownerRefs: Map>, + ) : Entity + + @Test + fun `Map with Ref values should round-trip through toDatabase and fromDatabase`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '{\"x\": 5, \"y\": 6}' AS owner_refs") + val result = query.getSingleResult(RefMapEntityForInsert::class) + assertNotNull(result) + assertEquals(5, result.ownerRefs["x"]?.id()) + assertEquals(6, result.ownerRefs["y"]?.id()) + } + + // -- Map without any Ref types (standard Map serializer) -- + + data class PlainMapHolder( + @PK val id: Int = 0, + @Json val metadata: Map, + ) : Entity + + @Test + fun `Map without Ref types should use standard serializer`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '{\"a\": 1, \"b\": 2}' AS metadata") + val result = query.getSingleResult(PlainMapHolder::class) + assertNotNull(result) + assertEquals(1, result.metadata["a"]) + assertEquals(2, result.metadata["b"]) + } + + // -- List of plain types (no Ref, standard serializer) -- + + data class PlainListHolder( + @PK val id: Int = 0, + @Json val names: List, + ) : Entity + + @Test + fun `List of plain types should use standard serializer`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '[\"a\", \"b\", \"c\"]' AS names") + val result = query.getSingleResult(PlainListHolder::class) + assertNotNull(result) + assertEquals(listOf("a", "b", "c"), result.names) + } +} diff --git a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterProviderTest.kt b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterProviderTest.kt new file mode 100644 index 000000000..0bb1ff8f8 --- /dev/null +++ b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterProviderTest.kt @@ -0,0 +1,64 @@ +package st.orm.serialization + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import st.orm.Entity +import st.orm.Json +import st.orm.PK +import st.orm.serialization.model.Address +import st.orm.template.ORMTemplate +import javax.sql.DataSource + +/** + * Tests for [st.orm.serialization.spi.JsonORMConverterProviderImpl] covering: + * - getConverter returns a working converter for fields with @Json + * - getConverter falls back to standard conversion for fields without @Json + * - Converter correctly deserializes JSON into domain objects + */ +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [IntegrationConfig::class]) +@DataJpaTest(showSql = false) +open class JsonORMConverterProviderTest( + @Autowired val dataSource: DataSource, +) { + + data class EntityWithJsonField( + @PK val id: Int = 0, + @Json val address: Address, + ) : Entity + + data class EntityWithoutJsonField( + @PK val id: Int = 0, + val name: String, + ) : Entity + + @Test + fun `field with Json annotation should be deserialized from JSON string into domain object`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '{\"address\": \"123 Main St\", \"city\": \"Springfield\"}' AS address") + val result = query.getSingleResult(EntityWithJsonField::class) + assertEquals("123 Main St", result.address.address) + assertEquals("Springfield", result.address.city) + } + + @Test + fun `field without Json annotation should use standard string conversion`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, 'testName' AS name") + val result = query.getSingleResult(EntityWithoutJsonField::class) + assertEquals("testName", result.name) + } + + @Test + fun `Json converter should preserve all fields through serialization round-trip`() { + val orm = ORMTemplate.of(dataSource) + val query = orm.query("SELECT 1 AS id, '{\"address\": \"456 Oak\", \"city\": \"Portland\"}' AS address") + val result = query.getSingleResult(EntityWithJsonField::class) + assertEquals(Address("456 Oak", "Portland"), result.address) + } +} diff --git a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/StormSerializersModuleCoverageTest.kt b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/StormSerializersModuleCoverageTest.kt new file mode 100644 index 000000000..86be08d51 --- /dev/null +++ b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/StormSerializersModuleCoverageTest.kt @@ -0,0 +1,332 @@ +package st.orm.serialization + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import st.orm.Data +import st.orm.Entity +import st.orm.PK +import st.orm.Projection +import st.orm.Ref + +/** + * Tests targeting remaining coverage gaps in StormSerializersModule.kt: + * - resolveClassFromSerialName with nested class name patterns + * - encodeId with known PK type and serializer (non-fallback path) + * - decodeId with known PK type and serializer + * - serializerOrNull returning null for unknown types + * - createRef with null id returning null + * - Map of refs exercising Ref in various collection positions + */ +class StormSerializersModuleCoverageTest { + + @Serializable + data class SimpleEntity( + @PK val id: Int = 0, + val name: String, + ) : Entity + + @Serializable + data class SimpleProjection( + @PK val id: Int = 0, + val name: String, + ) : Projection + + @Serializable + data class StringIdEntity( + @PK val id: String, + val name: String, + ) : Entity + + @Serializable + data class LongIdEntity( + @PK val id: Long, + val name: String, + ) : Entity + + @Serializable + data class DoubleIdEntity( + @PK val id: Double, + val name: String, + ) : Entity + + @Serializable + data class NoPkData( + val code: String, + val label: String, + ) : Data + + private val jsonMapper = Json { + serializersModule = StormSerializers + } + + // -- encodeId with known PK type exercising the non-fallback serializer path -- + + @Serializable + data class IntIdRefHolder(@Contextual val ref: Ref?) + + @Test + fun `encodeId uses PK type serializer for Int id`() { + // SimpleEntity has @PK val id: Int, so PkTypeResolver resolves Int. + // encodeId should use the serializer for Int, not the fallback. + val holder = IntIdRefHolder(ref = Ref.of(SimpleEntity::class.java, 42)) + val json = jsonMapper.encodeToString(holder) + json shouldBe """{"ref":42}""" + } + + @Test + fun `decodeId uses PK type serializer for Int id`() { + val holder = jsonMapper.decodeFromString("""{"ref":99}""") + holder.ref.shouldNotBeNull() + holder.ref!!.id() shouldBe 99 + } + + @Serializable + data class StringIdRefHolder(@Contextual val ref: Ref?) + + @Test + fun `encodeId uses PK type serializer for String id`() { + val holder = StringIdRefHolder(ref = Ref.of(StringIdEntity::class.java, "test-123")) + val json = jsonMapper.encodeToString(holder) + json shouldBe """{"ref":"test-123"}""" + } + + @Test + fun `decodeId uses PK type serializer for String id`() { + val holder = jsonMapper.decodeFromString("""{"ref":"xyz"}""") + holder.ref.shouldNotBeNull() + holder.ref!!.id() shouldBe "xyz" + } + + @Serializable + data class LongIdRefHolder(@Contextual val ref: Ref?) + + @Test + fun `encodeId uses PK type serializer for Long id`() { + val holder = LongIdRefHolder(ref = Ref.of(LongIdEntity::class.java, 9876543210L)) + val json = jsonMapper.encodeToString(holder) + json shouldBe """{"ref":9876543210}""" + } + + @Test + fun `decodeId uses PK type serializer for Long id`() { + val holder = jsonMapper.decodeFromString("""{"ref":9876543210}""") + holder.ref.shouldNotBeNull() + holder.ref!!.id() shouldBe 9876543210L + } + + @Serializable + data class DoubleIdRefHolder(@Contextual val ref: Ref?) + + @Test + fun `encodeId uses PK type serializer for Double id`() { + val holder = DoubleIdRefHolder(ref = Ref.of(DoubleIdEntity::class.java, 2.718)) + val json = jsonMapper.encodeToString(holder) + json shouldBe """{"ref":2.718}""" + } + + @Test + fun `decodeId uses PK type serializer for Double id`() { + val holder = jsonMapper.decodeFromString("""{"ref":2.718}""") + holder.ref.shouldNotBeNull() + holder.ref!!.id() shouldBe 2.718 + } + + // -- createRef with null id -- + + @Serializable + data class NullableRefHolder(@Contextual val ref: Ref?) + + @Test + fun `deserialize null JSON literal for ref produces null`() { + val holder = jsonMapper.decodeFromString("""{"ref":null}""") + holder.ref.shouldBeNull() + } + + // -- Map with Ref keys -- + + @Serializable + data class MapWithRefKeyHolder( + @Contextual val map: Map<@Contextual Ref, String>, + ) + + @Test + fun `serialize map with ref keys throws SerializationException`() { + // Ref as map key is not supported because the contextual serializer + // cannot determine the Ref target type from a map key position. + val map = mapOf( + Ref.of(SimpleEntity::class.java, 1) to "first", + Ref.of(SimpleEntity::class.java, 2) to "second", + ) + val holder = MapWithRefKeyHolder(map = map) + shouldThrow { + jsonMapper.encodeToString(holder) + } + } + + // -- Map with Ref values -- + + @Serializable + data class MapWithRefValueHolder( + @Contextual val map: Map>, + ) + + @Test + fun `serialize map with ref values encodes refs as raw ids`() { + val map = mapOf( + "a" to Ref.of(SimpleEntity::class.java, 10), + "b" to Ref.of(SimpleEntity::class.java, 20), + ) + val holder = MapWithRefValueHolder(map = map) + val json = jsonMapper.encodeToString(holder) + json shouldBe """{"map":{"a":10,"b":20}}""" + } + + @Test + fun `deserialize map with ref values`() { + val holder = jsonMapper.decodeFromString("""{"map":{"a":10,"b":20}}""") + holder.map["a"]!!.id() shouldBe 10 + holder.map["b"]!!.id() shouldBe 20 + } + + // -- Map with both Ref keys and Ref values -- + + @Serializable + data class MapWithBothRefHolder( + @Contextual val map: Map<@Contextual Ref, @Contextual Ref>, + ) + + @Test + fun `serialize map with both ref keys and ref values throws SerializationException`() { + // Ref as map key is not supported because the contextual serializer + // cannot determine the Ref target type from a map key position. + val map = mapOf( + Ref.of(SimpleEntity::class.java, 1) to Ref.of(LongIdEntity::class.java, 100L), + ) + val holder = MapWithBothRefHolder(map = map) + shouldThrow { + jsonMapper.encodeToString(holder) + } + } + + // -- Loaded entity ref with known PK serializer path -- + + @Test + fun `serialize loaded entity ref and round-trip with known PK type`() { + val entity = SimpleEntity(id = 55, name = "Covered") + val holder = IntIdRefHolder(ref = Ref.of(entity)) + val json = jsonMapper.encodeToString(holder) + json shouldBe """{"ref":{"@entity":{"id":55,"name":"Covered"}}}""" + + val deserialized = jsonMapper.decodeFromString(json) + val loaded = deserialized.ref?.getOrNull() + loaded.shouldNotBeNull() + loaded.id shouldBe 55 + loaded.name shouldBe "Covered" + } + + // -- Loaded projection ref with known PK serializer path -- + + @Serializable + data class ProjectionRefHolder(@Contextual val ref: Ref?) + + @Test + fun `loaded projection ref round-trips preserving id and all fields`() { + val projection = SimpleProjection(id = 33, name = "Proj") + val holder = ProjectionRefHolder(ref = Ref.of(projection, 33)) + val json = jsonMapper.encodeToString(holder) + // Projection refs serialize with @id (the PK) and @projection (the full object). + json shouldBe """{"ref":{"@id":33,"@projection":{"id":33,"name":"Proj"}}}""" + + val deserialized = jsonMapper.decodeFromString(json) + val loaded = deserialized.ref?.getOrNull() + loaded.shouldNotBeNull() + loaded.id shouldBe 33 + loaded.name shouldBe "Proj" + } + + // -- StormSerializersModule with custom factory exercising createRef -- + + @Test + fun `StormSerializersModule with custom refFactory that creates attached refs`() { + var factoryInvoked = false + val customJson = Json { + serializersModule = StormSerializersModule { + object : st.orm.core.spi.RefFactory { + override fun create(type: Class, pk: ID): Ref { + factoryInvoked = true + return Ref.of(type, pk) + } + override fun create(record: T, pk: ID): Ref = Ref.of(record.javaClass as Class, pk) + } + } + } + + val holder = customJson.decodeFromString("""{"ref":42}""") + holder.ref.shouldNotBeNull() + holder.ref!!.id() shouldBe 42 + factoryInvoked shouldBe true + } + + // -- Nested class serial name resolution -- + // Note: this exercises resolveClassFromSerialName's nested class path + // by using types whose serialName contains dots that need $ conversion. + + @Serializable + data class NestedInnerEntity( + @PK val id: Int = 0, + val value: String, + ) : Entity + + @Serializable + data class NestedRefHolder(@Contextual val ref: Ref?) + + @Test + fun `nested inner class entity ref round-trips through serialization correctly`() { + // This exercises resolveClassFromSerialName's nested class path, where the + // serial name contains dots that must be converted to $ for inner classes. + val entity = NestedInnerEntity(id = 7, value = "inner") + val holder = NestedRefHolder(ref = Ref.of(entity)) + val json = jsonMapper.encodeToString(holder) + json shouldBe """{"ref":{"@entity":{"id":7,"value":"inner"}}}""" + + val deserialized = jsonMapper.decodeFromString(json) + val loaded = deserialized.ref?.getOrNull() + loaded.shouldNotBeNull() + loaded.id shouldBe 7 + loaded.value shouldBe "inner" + } + + // -- encodeId with null ref (ref itself is null, not just id) -- + + @Test + fun `encodeId with null ref serializes to JSON null`() { + val holder = IntIdRefHolder(ref = null) + val json = jsonMapper.encodeToString(holder) + json shouldBe """{"ref":null}""" + } + + // -- Unloaded projection ref serialization (exercises encodeId for projection type) -- + + @Test + fun `serialize unloaded projection ref produces raw id`() { + val holder = ProjectionRefHolder(ref = Ref.of(SimpleProjection::class.java, 77)) + val json = jsonMapper.encodeToString(holder) + json shouldBe """{"ref":77}""" + } + + @Test + fun `deserialize raw id into unloaded projection ref`() { + val holder = jsonMapper.decodeFromString("""{"ref":77}""") + holder.ref.shouldNotBeNull() + holder.ref!!.id() shouldBe 77 + holder.ref!!.getOrNull().shouldBeNull() + } +} diff --git a/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBSqlDialectTest.java b/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBSqlDialectTest.java new file mode 100644 index 000000000..bc5c99742 --- /dev/null +++ b/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBSqlDialectTest.java @@ -0,0 +1,157 @@ +package st.orm.spi.mariadb; + +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; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import st.orm.PersistenceException; +import st.orm.StormConfig; + +/** + * Unit tests for {@link MariaDBSqlDialect} verifying MariaDB-specific behavior, + * especially where it diverges from the MySQL base class. + */ +class MariaDBSqlDialectTest { + + private final MariaDBSqlDialect dialect = new MariaDBSqlDialect(); + + // -- Escape: MariaDB inherits MySQL backtick escaping -- + + @Test + void escapeShouldWrapInBackticks() { + assertEquals("`myColumn`", dialect.escape("myColumn")); + } + + @Test + void escapeShouldDoubleEmbeddedBackticks() { + assertEquals("`my``Column`", dialect.escape("my`Column")); + } + + @Test + void escapeShouldHandleMultipleEmbeddedBackticks() { + assertEquals("`a``b``c`", dialect.escape("a`b`c")); + } + + // -- getSafeIdentifier: keyword + escape integration -- + + @Test + void getSafeIdentifierShouldEscapeMySQLKeywords() { + // MariaDB inherits MySQL keywords like FULLTEXT, OPTIMIZE. + assertEquals("`FULLTEXT`", dialect.getSafeIdentifier("FULLTEXT")); + assertEquals("`OPTIMIZE`", dialect.getSafeIdentifier("OPTIMIZE")); + } + + @Test + void getSafeIdentifierShouldNotEscapeNormalIdentifiers() { + assertEquals("myTable", dialect.getSafeIdentifier("myTable")); + assertEquals("_temp", dialect.getSafeIdentifier("_temp")); + } + + @Test + void getSafeIdentifierShouldEscapeIdentifiersWithSpaces() { + assertEquals("`my table`", dialect.getSafeIdentifier("my table")); + } + + @Test + void isKeywordShouldBeCaseInsensitive() { + assertTrue(dialect.isKeyword("fulltext")); + assertTrue(dialect.isKeyword("FULLTEXT")); + assertFalse(dialect.isKeyword("myColumn")); + } + + // -- Identifier pattern: matches both backtick and double-quote -- + + @Test + void identifierPatternShouldExtractBacktickIdentifiers() { + var matcher = dialect.getIdentifierPattern().matcher("SELECT `my col` FROM t"); + assertTrue(matcher.find()); + assertEquals("`my col`", matcher.group()); + } + + @Test + void identifierPatternShouldExtractDoubleQuotedIdentifiers() { + var matcher = dialect.getIdentifierPattern().matcher("SELECT \"my col\" FROM t"); + assertTrue(matcher.find()); + assertEquals("\"my col\"", matcher.group()); + } + + // -- Quote literal pattern -- + + @Test + void quoteLiteralPatternShouldMatchStringWithEscapedQuotes() { + var matcher = dialect.getQuoteLiteralPattern().matcher("'it''s a test'"); + assertTrue(matcher.find()); + assertEquals("'it''s a test'", matcher.group()); + } + + // -- MySQL-inherited limit/offset with MySQL's unusual offset workaround -- + + @Test + void offsetShouldUseLimitMaxWorkaround() { + // MySQL/MariaDB do not support standalone OFFSET; they use LIMIT MAX_BIGINT OFFSET N. + assertEquals("LIMIT 18446744073709551615 OFFSET 5", dialect.offset(5)); + } + + @Test + void limitWithOffsetShouldCombineLimitAndOffset() { + assertEquals("LIMIT 20 OFFSET 10", dialect.limit(10, 20)); + } + + // -- MariaDB's key behavioral override: sequence support -- + + @Test + void sequenceNextValShouldReturnNextValueForSyntax() { + // MariaDB overrides MySQL's throwing behavior to support sequences. + assertEquals("NEXT VALUE FOR my_seq", dialect.sequenceNextVal("my_seq")); + } + + @Test + void sequenceNextValShouldEscapeKeywordSequenceName() { + var result = dialect.sequenceNextVal("SELECT"); + assertEquals("NEXT VALUE FOR `SELECT`", result); + } + + @Test + void mariaDBSequenceSupportShouldDifferFromMySQL() { + // This is the critical behavioral difference: MariaDB supports sequences, MySQL does not. + var mariaResult = dialect.sequenceNextVal("test_seq"); + assertTrue(mariaResult.contains("NEXT VALUE FOR")); + + var mysqlDialect = new st.orm.spi.mysql.MySQLSqlDialect(); + assertThrows(PersistenceException.class, () -> mysqlDialect.sequenceNextVal("test_seq")); + } + + // -- Provider filter -- + + @Test + void providerFilterShouldAcceptNonSqlDialectProviders() { + assertTrue(MariaDBProviderFilter.INSTANCE.test(new st.orm.core.spi.Provider() {})); + } + + @Test + void providerFilterShouldRejectForeignSqlDialectProviders() { + assertFalse(MariaDBProviderFilter.INSTANCE.test(new st.orm.core.spi.SqlDialectProvider() { + @Override public st.orm.core.template.SqlDialect getSqlDialect(StormConfig config) { return null; } + })); + } + + @Test + void providerFilterShouldAcceptMariaDBEntityRepositoryProvider() { + assertTrue(MariaDBProviderFilter.INSTANCE.test(new MariaDBEntityRepositoryProviderImpl())); + } + + // -- SqlDialectProvider -- + + @Test + void sqlDialectProviderShouldReturnDialectWithMariaDBSpecificBehavior() { + var provider = new MariaDBSqlDialectProviderImpl(); + var sqlDialect = provider.getSqlDialect(StormConfig.of(Map.of())); + // MariaDB-specific: backtick escaping and LIMIT syntax (inherited from MySQL). + assertEquals("`col`", sqlDialect.escape("col")); + assertEquals("LIMIT 5", sqlDialect.limit(5)); + assertEquals("MariaDB", sqlDialect.name()); + } +} diff --git a/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/MSSQLServerSqlDialectTest.java b/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/MSSQLServerSqlDialectTest.java new file mode 100644 index 000000000..7db26f58a --- /dev/null +++ b/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/MSSQLServerSqlDialectTest.java @@ -0,0 +1,210 @@ +package st.orm.spi.mssqlserver; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import st.orm.StormConfig; + +/** + * Unit tests for {@link MSSQLServerSqlDialect} verifying MSSQL-specific SQL generation behavior. + */ +class MSSQLServerSqlDialectTest { + + private final MSSQLServerSqlDialect dialect = new MSSQLServerSqlDialect(); + + // -- Identifier validation: MSSQL allows _, #, and letters as first char -- + + @Test + void identifierPatternShouldAcceptHashPrefixedTempTables() { + // MSSQL temp tables start with #, which is not valid in standard SQL. + var pattern = dialect.getValidIdentifierPattern(); + assertTrue(pattern.matcher("#tempTable").matches()); + assertTrue(pattern.matcher("#globalTemp").matches()); + } + + @Test + void identifierPatternShouldAcceptUnderscorePrefixedNames() { + assertTrue(dialect.getValidIdentifierPattern().matcher("_internal").matches()); + } + + @Test + void identifierPatternShouldRejectNumericLeadingCharacters() { + assertFalse(dialect.getValidIdentifierPattern().matcher("123table").matches()); + } + + @Test + void identifierPatternShouldRejectEmptyString() { + assertFalse(dialect.getValidIdentifierPattern().matcher("").matches()); + } + + // -- Escape: MSSQL uses square bracket escaping [name] -- + + @Test + void escapeShouldWrapSimpleNameInSquareBrackets() { + assertEquals("[myColumn]", dialect.escape("myColumn")); + } + + @Test + void escapeShouldDoubleClosingBracketsInsideName() { + // A single ] in the name becomes ]], wrapped in [...] + assertEquals("[my]]Column]", dialect.escape("my]Column")); + } + + @Test + void escapeShouldHandleMultipleClosingBrackets() { + // Two consecutive ] each get doubled: ]] -> ]]]] + assertEquals("[a]]]]b]", dialect.escape("a]]b")); + } + + @Test + void escapeShouldHandleNameThatIsEntirelyClosingBracket() { + // Input "]]" → each ] doubled → "]]]]" → wrapped → "[]]]]]" + assertEquals("[]]]]]", dialect.escape("]]")); + } + + // -- getSafeIdentifier: integration of isKeyword + escape -- + + @Test + void getSafeIdentifierShouldEscapeMSSQLKeywords() { + // MERGE is an MSSQL-specific keyword; should be escaped with brackets. + assertEquals("[MERGE]", dialect.getSafeIdentifier("MERGE")); + assertEquals("[TOP]", dialect.getSafeIdentifier("TOP")); + assertEquals("[PIVOT]", dialect.getSafeIdentifier("PIVOT")); + } + + @Test + void getSafeIdentifierShouldNotEscapeNormalIdentifiers() { + assertEquals("myTable", dialect.getSafeIdentifier("myTable")); + assertEquals("col1", dialect.getSafeIdentifier("col1")); + } + + @Test + void getSafeIdentifierShouldEscapeIdentifiersWithInvalidCharacters() { + // Identifiers with spaces or special chars should be escaped. + assertEquals("[my table]", dialect.getSafeIdentifier("my table")); + assertEquals("[my-column]", dialect.getSafeIdentifier("my-column")); + } + + @Test + void isKeywordShouldBeCaseInsensitive() { + assertTrue(dialect.isKeyword("merge")); + assertTrue(dialect.isKeyword("MERGE")); + assertTrue(dialect.isKeyword("Merge")); + } + + @Test + void isKeywordShouldRejectNonKeywords() { + assertFalse(dialect.isKeyword("myColumn")); + assertFalse(dialect.isKeyword("customer_name")); + } + + // -- Identifier pattern: recognizes [bracket] and "double-quoted" identifiers -- + + @Test + void identifierPatternShouldExtractSquareBracketIdentifiers() { + var matcher = dialect.getIdentifierPattern().matcher("SELECT [my col] FROM t"); + assertTrue(matcher.find()); + assertEquals("[my col]", matcher.group()); + } + + @Test + void identifierPatternShouldExtractDoubleQuotedIdentifiers() { + var matcher = dialect.getIdentifierPattern().matcher("SELECT \"my col\" FROM t"); + assertTrue(matcher.find()); + assertEquals("\"my col\"", matcher.group()); + } + + @Test + void identifierPatternShouldNotMatchUnquotedIdentifiers() { + var matcher = dialect.getIdentifierPattern().matcher("SELECT myCol FROM t"); + assertFalse(matcher.find()); + } + + // -- Quote literal pattern -- + + @Test + void quoteLiteralPatternShouldMatchStringWithEscapedQuotes() { + var matcher = dialect.getQuoteLiteralPattern().matcher("'it''s a test'"); + assertTrue(matcher.find()); + assertEquals("'it''s a test'", matcher.group()); + } + + // -- MSSQL-specific limit/offset: TOP clause and OFFSET-FETCH -- + + @Test + void limitShouldGenerateTopClause() { + // MSSQL uses TOP N instead of LIMIT N. + assertEquals("TOP 10", dialect.limit(10)); + assertEquals("TOP 1", dialect.limit(1)); + assertEquals("TOP 0", dialect.limit(0)); + } + + @Test + void offsetShouldGenerateOffsetRows() { + assertEquals("OFFSET 5 ROWS", dialect.offset(5)); + assertEquals("OFFSET 0 ROWS", dialect.offset(0)); + } + + @Test + void limitWithOffsetShouldGenerateOffsetFetchNext() { + // MSSQL 2012+ uses OFFSET-FETCH, not LIMIT-OFFSET. + assertEquals("OFFSET 10 ROWS FETCH NEXT 20 ROWS ONLY", dialect.limit(10, 20)); + assertEquals("OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY", dialect.limit(0, 1)); + } + + // -- MSSQL-specific lock hints: WITH (HOLDLOCK), WITH (UPDLOCK) -- + + @Test + void lockHintsShouldUseWithSyntax() { + // MSSQL uses WITH (...) hints instead of FOR UPDATE / FOR SHARE. + assertEquals("WITH (HOLDLOCK)", dialect.forShareLockHint()); + assertEquals("WITH (UPDLOCK)", dialect.forUpdateLockHint()); + } + + // -- Sequence SQL generation -- + + @Test + void sequenceNextValShouldGenerateCorrectSqlForSimpleName() { + assertEquals("NEXT VALUE FOR my_seq", dialect.sequenceNextVal("my_seq")); + } + + @Test + void sequenceNextValShouldEscapeKeywordSequenceName() { + // If the sequence name is a keyword, getSafeIdentifier should escape it. + var result = dialect.sequenceNextVal("SELECT"); + assertEquals("NEXT VALUE FOR [SELECT]", result); + } + + // -- Provider filter: accept/reject logic -- + + @Test + void providerFilterShouldAcceptNonSqlDialectProviders() { + assertTrue(MSSQLServerProviderFilter.INSTANCE.test(new st.orm.core.spi.Provider() {})); + } + + @Test + void providerFilterShouldRejectForeignSqlDialectProviders() { + assertFalse(MSSQLServerProviderFilter.INSTANCE.test(new st.orm.core.spi.SqlDialectProvider() { + @Override public st.orm.core.template.SqlDialect getSqlDialect(StormConfig config) { return null; } + })); + } + + @Test + void providerFilterShouldAcceptMSSQLServerEntityRepositoryProvider() { + assertTrue(MSSQLServerProviderFilter.INSTANCE.test(new MSSQLServerEntityRepositoryProviderImpl())); + } + + // -- SqlDialectProvider -- + + @Test + void sqlDialectProviderShouldReturnDialectWithMSSQLSpecificBehavior() { + var provider = new MSSQLServerSqlDialectProviderImpl(); + var sqlDialect = provider.getSqlDialect(StormConfig.of(Map.of())); + // Verify MSSQL-specific: bracket escaping and TOP syntax. + assertEquals("[col]", sqlDialect.escape("col")); + assertEquals("TOP 5", sqlDialect.limit(5)); + } +} diff --git a/storm-mysql/src/test/java/st/orm/spi/mysql/MySQLSqlDialectTest.java b/storm-mysql/src/test/java/st/orm/spi/mysql/MySQLSqlDialectTest.java new file mode 100644 index 000000000..48e7b5279 --- /dev/null +++ b/storm-mysql/src/test/java/st/orm/spi/mysql/MySQLSqlDialectTest.java @@ -0,0 +1,176 @@ +package st.orm.spi.mysql; + +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; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import st.orm.PersistenceException; +import st.orm.StormConfig; + +/** + * Unit tests for {@link MySQLSqlDialect} verifying MySQL-specific SQL generation behavior. + */ +class MySQLSqlDialectTest { + + private final MySQLSqlDialect dialect = new MySQLSqlDialect(); + + // -- Identifier validation: MySQL allows underscore prefix -- + + @Test + void identifierPatternShouldAcceptUnderscorePrefixedNames() { + // MySQL (unlike ANSI/Oracle/PostgreSQL) allows _ as first character. + assertTrue(dialect.getValidIdentifierPattern().matcher("_temp").matches()); + } + + @Test + void identifierPatternShouldRejectNumericLeadingCharacters() { + assertFalse(dialect.getValidIdentifierPattern().matcher("123start").matches()); + } + + @Test + void identifierPatternShouldRejectEmptyString() { + assertFalse(dialect.getValidIdentifierPattern().matcher("").matches()); + } + + // -- Escape: MySQL uses backtick escaping -- + + @Test + void escapeShouldWrapInBackticks() { + assertEquals("`myColumn`", dialect.escape("myColumn")); + } + + @Test + void escapeShouldDoubleEmbeddedBackticks() { + assertEquals("`my``Column`", dialect.escape("my`Column")); + } + + @Test + void escapeShouldHandleMultipleEmbeddedBackticks() { + assertEquals("`a``b``c`", dialect.escape("a`b`c")); + } + + // -- getSafeIdentifier: keyword + escape integration -- + + @Test + void getSafeIdentifierShouldEscapeMySQLSpecificKeywords() { + // FULLTEXT, UNSIGNED, ZEROFILL are MySQL-specific keywords. + assertEquals("`FULLTEXT`", dialect.getSafeIdentifier("FULLTEXT")); + assertEquals("`UNSIGNED`", dialect.getSafeIdentifier("UNSIGNED")); + assertEquals("`ZEROFILL`", dialect.getSafeIdentifier("ZEROFILL")); + } + + @Test + void getSafeIdentifierShouldNotEscapeNormalIdentifiers() { + assertEquals("myTable", dialect.getSafeIdentifier("myTable")); + assertEquals("_temp", dialect.getSafeIdentifier("_temp")); + } + + @Test + void getSafeIdentifierShouldEscapeIdentifiersWithSpaces() { + assertEquals("`my table`", dialect.getSafeIdentifier("my table")); + } + + @Test + void isKeywordShouldBeCaseInsensitive() { + assertTrue(dialect.isKeyword("fulltext")); + assertTrue(dialect.isKeyword("FULLTEXT")); + assertFalse(dialect.isKeyword("myColumn")); + } + + // -- Identifier pattern: matches backtick and double-quote identifiers -- + + @Test + void identifierPatternShouldExtractBacktickIdentifiers() { + var matcher = dialect.getIdentifierPattern().matcher("SELECT `my col` FROM t"); + assertTrue(matcher.find()); + assertEquals("`my col`", matcher.group()); + } + + @Test + void identifierPatternShouldExtractDoubleQuotedIdentifiers() { + var matcher = dialect.getIdentifierPattern().matcher("SELECT \"my col\" FROM t"); + assertTrue(matcher.find()); + assertEquals("\"my col\"", matcher.group()); + } + + @Test + void identifierPatternShouldNotMatchUnquotedText() { + assertFalse(dialect.getIdentifierPattern().matcher("SELECT myCol FROM t").find()); + } + + // -- Quote literal pattern -- + + @Test + void quoteLiteralPatternShouldMatchStringWithEscapedQuotes() { + var matcher = dialect.getQuoteLiteralPattern().matcher("'it''s a test'"); + assertTrue(matcher.find()); + assertEquals("'it''s a test'", matcher.group()); + } + + // -- MySQL-specific limit/offset with unusual offset workaround -- + + @Test + void limitShouldGenerateLimitClause() { + assertEquals("LIMIT 10", dialect.limit(10)); + assertEquals("LIMIT 0", dialect.limit(0)); + } + + @Test + void offsetShouldUseLimitMaxBigintWorkaround() { + // MySQL does not support standalone OFFSET; uses LIMIT MAX_BIGINT OFFSET N. + assertEquals("LIMIT 18446744073709551615 OFFSET 5", dialect.offset(5)); + assertEquals("LIMIT 18446744073709551615 OFFSET 0", dialect.offset(0)); + } + + @Test + void limitWithOffsetShouldCombineLimitAndOffset() { + assertEquals("LIMIT 20 OFFSET 10", dialect.limit(10, 20)); + } + + // -- MySQL sequence: not supported, should throw -- + + @Test + void sequenceNextValShouldThrowPersistenceException() { + assertThrows(PersistenceException.class, () -> dialect.sequenceNextVal("my_seq")); + } + + @Test + void sequenceNextValExceptionShouldContainMeaningfulMessage() { + var exception = assertThrows(PersistenceException.class, () -> dialect.sequenceNextVal("my_seq")); + assertTrue(exception.getMessage().contains("MySQL")); + assertTrue(exception.getMessage().contains("sequence")); + } + + // -- Provider filter -- + + @Test + void providerFilterShouldAcceptNonSqlDialectProviders() { + assertTrue(MySQLProviderFilter.INSTANCE.test(new st.orm.core.spi.Provider() {})); + } + + @Test + void providerFilterShouldRejectForeignSqlDialectProviders() { + assertFalse(MySQLProviderFilter.INSTANCE.test(new st.orm.core.spi.SqlDialectProvider() { + @Override public st.orm.core.template.SqlDialect getSqlDialect(StormConfig config) { return null; } + })); + } + + @Test + void providerFilterShouldAcceptMySQLEntityRepositoryProvider() { + assertTrue(MySQLProviderFilter.INSTANCE.test(new MySQLEntityRepositoryProviderImpl())); + } + + // -- SqlDialectProvider -- + + @Test + void sqlDialectProviderShouldReturnDialectWithMySQLSpecificBehavior() { + var provider = new MySQLSqlDialectProviderImpl(); + var sqlDialect = provider.getSqlDialect(StormConfig.of(Map.of())); + // MySQL-specific: backtick escaping and LIMIT syntax. + assertEquals("`col`", sqlDialect.escape("col")); + assertEquals("LIMIT 5", sqlDialect.limit(5)); + } +} diff --git a/storm-oracle/src/test/java/st/orm/spi/oracle/OracleSqlDialectTest.java b/storm-oracle/src/test/java/st/orm/spi/oracle/OracleSqlDialectTest.java new file mode 100644 index 000000000..22c4b2e34 --- /dev/null +++ b/storm-oracle/src/test/java/st/orm/spi/oracle/OracleSqlDialectTest.java @@ -0,0 +1,153 @@ +package st.orm.spi.oracle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import st.orm.StormConfig; + +/** + * Unit tests for {@link OracleSqlDialect} verifying Oracle-specific SQL generation behavior. + */ +class OracleSqlDialectTest { + + private final OracleSqlDialect dialect = new OracleSqlDialect(); + + // -- Escape: Oracle uses double-quote escaping -- + + @Test + void escapeShouldWrapInDoubleQuotes() { + assertEquals("\"myColumn\"", dialect.escape("myColumn")); + } + + @Test + void escapeShouldDoubleEmbeddedDoubleQuotes() { + assertEquals("\"my\"\"Column\"", dialect.escape("my\"Column")); + } + + @Test + void escapeShouldHandleMultipleEmbeddedDoubleQuotes() { + assertEquals("\"a\"\"b\"\"c\"", dialect.escape("a\"b\"c")); + } + + // -- getSafeIdentifier: keyword + escape integration -- + + @Test + void getSafeIdentifierShouldEscapeOracleSpecificKeywords() { + // ROWNUM, ROWID, SYNONYM, VARCHAR2 are Oracle-specific keywords. + assertEquals("\"ROWNUM\"", dialect.getSafeIdentifier("ROWNUM")); + assertEquals("\"ROWID\"", dialect.getSafeIdentifier("ROWID")); + assertEquals("\"VARCHAR2\"", dialect.getSafeIdentifier("VARCHAR2")); + } + + @Test + void getSafeIdentifierShouldNotEscapeNormalIdentifiers() { + assertEquals("myTable", dialect.getSafeIdentifier("myTable")); + } + + @Test + void getSafeIdentifierShouldEscapeIdentifiersWithInvalidCharacters() { + assertEquals("\"my table\"", dialect.getSafeIdentifier("my table")); + assertEquals("\"my-col\"", dialect.getSafeIdentifier("my-col")); + } + + @Test + void isKeywordShouldBeCaseInsensitive() { + assertTrue(dialect.isKeyword("rownum")); + assertTrue(dialect.isKeyword("ROWNUM")); + assertFalse(dialect.isKeyword("myColumn")); + } + + // -- Identifier pattern -- + + @Test + void identifierPatternShouldExtractDoubleQuotedIdentifiers() { + var matcher = dialect.getIdentifierPattern().matcher("SELECT \"my col\" FROM t"); + assertTrue(matcher.find()); + assertEquals("\"my col\"", matcher.group()); + } + + // -- Quote literal pattern -- + + @Test + void quoteLiteralPatternShouldMatchStringWithEscapedQuotes() { + var matcher = dialect.getQuoteLiteralPattern().matcher("'it''s a test'"); + assertTrue(matcher.find()); + assertEquals("'it''s a test'", matcher.group()); + } + + // -- Oracle-specific limit/offset: FETCH FIRST / OFFSET ROWS syntax -- + + @Test + void limitShouldUseFetchFirstSyntax() { + // Oracle uses FETCH FIRST N ROWS ONLY, not LIMIT N. + assertEquals("FETCH FIRST 10 ROWS ONLY", dialect.limit(10)); + assertEquals("FETCH FIRST 1 ROWS ONLY", dialect.limit(1)); + assertEquals("FETCH FIRST 0 ROWS ONLY", dialect.limit(0)); + } + + @Test + void offsetShouldUseOffsetRowsSyntax() { + assertEquals("OFFSET 5 ROWS", dialect.offset(5)); + assertEquals("OFFSET 0 ROWS", dialect.offset(0)); + } + + @Test + void limitWithOffsetShouldUseFetchNextSyntax() { + assertEquals("OFFSET 10 ROWS FETCH NEXT 20 ROWS ONLY", dialect.limit(10, 20)); + assertEquals("OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY", dialect.limit(0, 1)); + } + + // -- Oracle-specific lock hints: no shared lock, FOR UPDATE -- + + @Test + void forShareLockShouldReturnEmptyBecauseOracleDoesNotSupportSharedLocks() { + // Oracle does not support a shared lock hint, so it returns empty string. + assertEquals("", dialect.forShareLockHint()); + } + + // -- Sequence SQL: Oracle-specific .NEXTVAL syntax -- + + @Test + void sequenceNextValShouldGenerateDotNextvalSyntax() { + assertEquals("my_seq.NEXTVAL", dialect.sequenceNextVal("my_seq")); + } + + @Test + void sequenceNextValShouldEscapeKeywordSequenceName() { + var result = dialect.sequenceNextVal("SELECT"); + assertEquals("\"SELECT\".NEXTVAL", result); + } + + // -- Provider filter -- + + @Test + void providerFilterShouldAcceptNonSqlDialectProviders() { + assertTrue(OracleProviderFilter.INSTANCE.test(new st.orm.core.spi.Provider() {})); + } + + @Test + void providerFilterShouldRejectForeignSqlDialectProviders() { + assertFalse(OracleProviderFilter.INSTANCE.test(new st.orm.core.spi.SqlDialectProvider() { + @Override public st.orm.core.template.SqlDialect getSqlDialect(StormConfig config) { return null; } + })); + } + + @Test + void providerFilterShouldAcceptOracleEntityRepositoryProvider() { + assertTrue(OracleProviderFilter.INSTANCE.test(new OracleEntityRepositoryProviderImpl())); + } + + // -- SqlDialectProvider -- + + @Test + void sqlDialectProviderShouldReturnDialectWithOracleSpecificBehavior() { + var provider = new OracleSqlDialectProviderImpl(); + var sqlDialect = provider.getSqlDialect(StormConfig.of(Map.of())); + // Oracle-specific: double-quote escaping and FETCH FIRST syntax. + assertEquals("\"col\"", sqlDialect.escape("col")); + assertEquals("FETCH FIRST 5 ROWS ONLY", sqlDialect.limit(5)); + } +} diff --git a/storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLSqlDialectTest.java b/storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLSqlDialectTest.java new file mode 100644 index 000000000..0d8452290 --- /dev/null +++ b/storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLSqlDialectTest.java @@ -0,0 +1,166 @@ +package st.orm.spi.postgresql; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import st.orm.StormConfig; + +/** + * Unit tests for {@link PostgreSQLSqlDialect} verifying PostgreSQL-specific SQL generation behavior. + */ +class PostgreSQLSqlDialectTest { + + private final PostgreSQLSqlDialect dialect = new PostgreSQLSqlDialect(); + + // -- Identifier validation -- + + @Test + void identifierPatternShouldRejectUnderscorePrefixedNames() { + assertFalse(dialect.getValidIdentifierPattern().matcher("_temp").matches()); + } + + @Test + void identifierPatternShouldRejectNumericLeadingCharacters() { + assertFalse(dialect.getValidIdentifierPattern().matcher("123start").matches()); + } + + // -- Escape: PostgreSQL uses double-quote escaping -- + + @Test + void escapeShouldWrapInDoubleQuotes() { + assertEquals("\"myColumn\"", dialect.escape("myColumn")); + } + + @Test + void escapeShouldDoubleEmbeddedDoubleQuotes() { + assertEquals("\"my\"\"Column\"", dialect.escape("my\"Column")); + } + + @Test + void escapeShouldHandleMultipleEmbeddedDoubleQuotes() { + assertEquals("\"a\"\"b\"\"c\"", dialect.escape("a\"b\"c")); + } + + // -- getSafeIdentifier: keyword + escape integration -- + + @Test + void getSafeIdentifierShouldEscapePostgreSQLSpecificKeywords() { + assertEquals("\"ILIKE\"", dialect.getSafeIdentifier("ILIKE")); + assertEquals("\"RETURNING\"", dialect.getSafeIdentifier("RETURNING")); + assertEquals("\"SERIAL\"", dialect.getSafeIdentifier("SERIAL")); + } + + @Test + void getSafeIdentifierShouldNotEscapeNormalIdentifiers() { + assertEquals("myTable", dialect.getSafeIdentifier("myTable")); + } + + @Test + void getSafeIdentifierShouldEscapeIdentifiersWithSpaces() { + assertEquals("\"my table\"", dialect.getSafeIdentifier("my table")); + } + + @Test + void isKeywordShouldBeCaseInsensitive() { + assertTrue(dialect.isKeyword("ilike")); + assertTrue(dialect.isKeyword("ILIKE")); + assertFalse(dialect.isKeyword("myColumn")); + } + + // -- Identifier pattern: extraction from SQL text -- + + @Test + void identifierPatternShouldExtractDoubleQuotedIdentifiers() { + var matcher = dialect.getIdentifierPattern().matcher("SELECT \"my col\" FROM t"); + assertTrue(matcher.find()); + assertEquals("\"my col\"", matcher.group()); + } + + @Test + void identifierPatternShouldNotMatchUnquotedText() { + assertFalse(dialect.getIdentifierPattern().matcher("SELECT myCol FROM t").find()); + } + + // -- Quote literal pattern -- + + @Test + void quoteLiteralPatternShouldMatchStringWithEscapedQuotes() { + var matcher = dialect.getQuoteLiteralPattern().matcher("'it''s a test'"); + assertTrue(matcher.find()); + assertEquals("'it''s a test'", matcher.group()); + } + + // -- PostgreSQL-specific limit/offset syntax -- + + @Test + void limitShouldGenerateLimitClause() { + assertEquals("LIMIT 10", dialect.limit(10)); + assertEquals("LIMIT 0", dialect.limit(0)); + } + + @Test + void offsetShouldGeneratePlainOffsetWithoutRowsKeyword() { + // PostgreSQL uses OFFSET N (not OFFSET N ROWS like Oracle/MSSQL). + assertEquals("OFFSET 5", dialect.offset(5)); + assertEquals("OFFSET 0", dialect.offset(0)); + } + + @Test + void limitWithOffsetShouldPutOffsetBeforeLimit() { + assertEquals("OFFSET 10 LIMIT 20", dialect.limit(10, 20)); + } + + // -- PostgreSQL-specific lock hints -- + + @Test + void forShareLockShouldUseForKeyShare() { + // PostgreSQL uses FOR KEY SHARE (weaker than FOR SHARE) to allow concurrent inserts. + assertEquals("FOR KEY SHARE", dialect.forShareLockHint()); + } + + // -- Sequence SQL: PostgreSQL nextval('name') -- + + @Test + void sequenceNextValShouldGenerateNextvalFunction() { + assertEquals("nextval('my_seq')", dialect.sequenceNextVal("my_seq")); + } + + @Test + void sequenceNextValShouldEscapeKeywordSequenceName() { + var result = dialect.sequenceNextVal("SELECT"); + assertTrue(result.startsWith("nextval('")); + assertTrue(result.contains("SELECT")); + } + + // -- Provider filter -- + + @Test + void providerFilterShouldAcceptNonSqlDialectProviders() { + assertTrue(PostgreSQLProviderFilter.INSTANCE.test(new st.orm.core.spi.Provider() {})); + } + + @Test + void providerFilterShouldRejectForeignSqlDialectProviders() { + assertFalse(PostgreSQLProviderFilter.INSTANCE.test(new st.orm.core.spi.SqlDialectProvider() { + @Override public st.orm.core.template.SqlDialect getSqlDialect(StormConfig config) { return null; } + })); + } + + @Test + void providerFilterShouldAcceptPostgreSQLEntityRepositoryProvider() { + assertTrue(PostgreSQLProviderFilter.INSTANCE.test(new PostgreSQLEntityRepositoryProviderImpl())); + } + + // -- SqlDialectProvider -- + + @Test + void sqlDialectProviderShouldReturnDialectWithPostgreSQLSpecificBehavior() { + var provider = new PostgreSQLSqlDialectProviderImpl(); + var sqlDialect = provider.getSqlDialect(StormConfig.of(Map.of())); + assertEquals("\"col\"", sqlDialect.escape("col")); + assertEquals("LIMIT 5", sqlDialect.limit(5)); + } +} diff --git a/storm-spring-boot-starter/src/test/java/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.java b/storm-spring-boot-starter/src/test/java/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.java index a629de4d4..8d0925ba1 100644 --- a/storm-spring-boot-starter/src/test/java/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.java +++ b/storm-spring-boot-starter/src/test/java/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.java @@ -172,6 +172,47 @@ void userDefinedRepositoryBeanFactoryPostProcessorTakesPrecedence() { }); } + @Test + void schemaValidationWarnModeShouldNotPreventContextStartup() { + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:schemaWarnTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.validation.schema-mode=warn" + ) + .run(context -> { + assertThat(context).hasSingleBean(ORMTemplate.class); + }); + } + + @Test + void schemaValidationNoneModeShouldNotPreventContextStartup() { + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:schemaNoneTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.validation.schema-mode=none" + ) + .run(context -> { + assertThat(context).hasSingleBean(ORMTemplate.class); + }); + } + + @Test + void strictValidationPropertyShouldBeBoundCorrectly() { + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:strictTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.validation.strict=true" + ) + .run(context -> { + assertThat(context).hasSingleBean(ORMTemplate.class); + StormProperties props = context.getBean(StormProperties.class); + assertThat(props.getValidation().getStrict()).isTrue(); + }); + } + @Configuration static class EntityCallbackConfig { @Bean diff --git a/storm-test/src/test/java/st/orm/test/SimpleDataSourceTest.java b/storm-test/src/test/java/st/orm/test/SimpleDataSourceTest.java new file mode 100644 index 000000000..cb8154480 --- /dev/null +++ b/storm-test/src/test/java/st/orm/test/SimpleDataSourceTest.java @@ -0,0 +1,75 @@ +package st.orm.test; + +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; + +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; + +/** + * Tests for the SimpleDataSource inner class of {@link StormExtension}, + * verifying that it provides working connections and correctly implements + * the DataSource interface contract, including error paths. + */ +@StormTest +class SimpleDataSourceTest { + + @Test + void connectionShouldBeUsableForSqlExecution(DataSource dataSource) throws Exception { + try (var conn = dataSource.getConnection(); + var stmt = conn.createStatement(); + var rs = stmt.executeQuery("SELECT 1 + 1")) { + assertTrue(rs.next()); + assertEquals(2, rs.getInt(1)); + } + } + + @Test + void connectionWithCredentialsShouldBeUsableForSqlExecution(DataSource dataSource) throws Exception { + try (var conn = dataSource.getConnection("sa", ""); + var stmt = conn.createStatement(); + var rs = stmt.executeQuery("SELECT 1 + 1")) { + assertTrue(rs.next()); + assertEquals(2, rs.getInt(1)); + } + } + + @Test + void multipleConnectionsShouldShareSameDatabase(DataSource dataSource) throws Exception { + // Create a table via one connection, verify via another. + try (var conn1 = dataSource.getConnection()) { + conn1.createStatement().execute("CREATE TABLE IF NOT EXISTS ds_test (id INT)"); + conn1.createStatement().execute("INSERT INTO ds_test VALUES (42)"); + } + try (var conn2 = dataSource.getConnection(); + var stmt = conn2.createStatement(); + var rs = stmt.executeQuery("SELECT id FROM ds_test")) { + assertTrue(rs.next()); + assertEquals(42, rs.getInt(1)); + } + // Cleanup. + try (var conn = dataSource.getConnection()) { + conn.createStatement().execute("DROP TABLE ds_test"); + } + } + + @Test + void getParentLoggerShouldThrowBecauseJulNotSupported(DataSource dataSource) { + // SimpleDataSource does not support java.util.logging. + assertThrows(SQLFeatureNotSupportedException.class, dataSource::getParentLogger); + } + + @Test + void unwrapShouldThrowBecauseNotAWrapper(DataSource dataSource) { + assertThrows(SQLException.class, () -> dataSource.unwrap(DataSource.class)); + } + + @Test + void isWrapperForShouldReturnFalse(DataSource dataSource) throws Exception { + assertFalse(dataSource.isWrapperFor(DataSource.class)); + } +} diff --git a/storm-test/src/test/java/st/orm/test/StormExtensionAdditionalTest.java b/storm-test/src/test/java/st/orm/test/StormExtensionAdditionalTest.java new file mode 100644 index 000000000..a30da770e --- /dev/null +++ b/storm-test/src/test/java/st/orm/test/StormExtensionAdditionalTest.java @@ -0,0 +1,72 @@ +package st.orm.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import st.orm.Entity; +import st.orm.PK; +import st.orm.core.template.ORMTemplate; +import st.orm.core.template.impl.SchemaValidator; + +/** + * Additional tests for {@link StormExtension} covering edge cases: + * - Custom URL + * - SchemaValidator parameter injection + * - Empty scripts array (default) + * - ORMTemplate factory method resolution + */ +@StormTest(scripts = {"/test-schema.sql", "/test-data.sql"}) +class StormExtensionAdditionalTest { + + record Item(@PK Integer id, String name) implements Entity {} + + @Test + void schemaValidatorShouldBeInjected(SchemaValidator validator) { + assertNotNull(validator); + } + + @Test + void ormTemplateShouldSupportEntityOperations(ORMTemplate orm) { + var items = orm.entity(Item.class).findAll(); + assertEquals(3, items.size()); + } + + @Test + void statementCaptureExecuteShouldReturnResult(ORMTemplate orm, StatementCapture capture) { + var items = capture.execute(() -> orm.entity(Item.class).findAll()); + // At least 3 rows from test data; may be more if insert tests run in the same class. + assertTrue(items.size() >= 3); + assertEquals(1, capture.count(CapturedStatement.Operation.SELECT)); + } + + @Test + void statementCaptureExecuteThrowingShouldReturnResult(ORMTemplate orm, StatementCapture capture) throws Exception { + var items = capture.executeThrowing(() -> orm.entity(Item.class).findAll()); + assertTrue(items.size() >= 3); + assertEquals(1, capture.count(CapturedStatement.Operation.SELECT)); + } + + @Test + void statementCaptureStatementsShouldReturnFilteredStatements(ORMTemplate orm, StatementCapture capture) { + capture.run(() -> orm.entity(Item.class).findAll()); + capture.run(() -> orm.entity(Item.class).insert(new Item(0, "Echo"))); + + var selects = capture.statements(CapturedStatement.Operation.SELECT); + var inserts = capture.statements(CapturedStatement.Operation.INSERT); + assertEquals(1, selects.size()); + assertEquals(1, inserts.size()); + assertEquals(0, capture.statements(CapturedStatement.Operation.DELETE).size()); + assertEquals(0, capture.count(CapturedStatement.Operation.DELETE)); + } + + @Test + void statementCaptureShouldReturnAllStatements(ORMTemplate orm, StatementCapture capture) { + capture.run(() -> orm.entity(Item.class).findAll()); + var allStatements = capture.statements(); + assertEquals(1, allStatements.size()); + assertNotNull(allStatements.getFirst().statement()); + assertNotNull(allStatements.getFirst().parameters()); + } +} diff --git a/storm-test/src/test/java/st/orm/test/StormExtensionCustomUrlTest.java b/storm-test/src/test/java/st/orm/test/StormExtensionCustomUrlTest.java new file mode 100644 index 000000000..f8c904227 --- /dev/null +++ b/storm-test/src/test/java/st/orm/test/StormExtensionCustomUrlTest.java @@ -0,0 +1,36 @@ +package st.orm.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import st.orm.Entity; +import st.orm.PK; +import st.orm.core.template.ORMTemplate; + +/** + * Tests the custom URL path in {@link StormExtension} where a non-default JDBC URL is provided. + * Verifies that the custom URL is actually used and that scripts execute against it. + */ +@StormTest(url = "jdbc:h2:mem:custom_url_test;DB_CLOSE_DELAY=-1", scripts = {"/test-schema.sql", "/test-data.sql"}) +class StormExtensionCustomUrlTest { + + record Item(@PK Integer id, String name) implements Entity {} + + @Test + void scriptsShouldExecuteAgainstCustomUrl(ORMTemplate orm) { + // The custom URL database should contain the test data loaded by the scripts. + var items = orm.entity(Item.class).findAll(); + assertEquals(3, items.size()); + } + + @Test + void customUrlShouldBeReflectedInConnection(DataSource dataSource) throws Exception { + try (var conn = dataSource.getConnection()) { + var url = conn.getMetaData().getURL(); + assertTrue(url.contains("custom_url_test"), + "Expected connection URL to contain 'custom_url_test' but got: " + url); + } + } +} diff --git a/storm-test/src/test/java/st/orm/test/StormExtensionMissingScriptTest.java b/storm-test/src/test/java/st/orm/test/StormExtensionMissingScriptTest.java new file mode 100644 index 000000000..ef9ed50ab --- /dev/null +++ b/storm-test/src/test/java/st/orm/test/StormExtensionMissingScriptTest.java @@ -0,0 +1,25 @@ +package st.orm.test; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests the error path in {@link StormExtension} when a missing script is referenced. + * This is tested indirectly by verifying the beforeAll behavior with reflection, since + * annotating a class with a missing script would cause the test class itself to fail to load. + */ +class StormExtensionMissingScriptTest { + + @Test + void readScriptWithMissingPathShouldThrowIllegalArgumentException() throws Exception { + // The readScript method is private static, so we use reflection to test it. + var method = StormExtension.class.getDeclaredMethod("readScript", String.class); + method.setAccessible(true); + var exception = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> method.invoke(null, "/nonexistent-script.sql")); + assertTrue(exception.getCause() instanceof IllegalArgumentException); + assertTrue(exception.getCause().getMessage().contains("Script not found on classpath")); + } +} diff --git a/storm-test/src/test/java/st/orm/test/StormExtensionNoScriptsTest.java b/storm-test/src/test/java/st/orm/test/StormExtensionNoScriptsTest.java new file mode 100644 index 000000000..0e4272c5b --- /dev/null +++ b/storm-test/src/test/java/st/orm/test/StormExtensionNoScriptsTest.java @@ -0,0 +1,35 @@ +package st.orm.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.SQLException; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import st.orm.core.template.ORMTemplate; + +/** + * Tests the path where {@link StormTest} is used with no scripts (default empty array). + * This exercises the beforeAll path where scripts.length == 0 and no SQL execution occurs, + * verifying that the extension still provides a functional DataSource and ORMTemplate. + */ +@StormTest +class StormExtensionNoScriptsTest { + + @Test + void shouldProvideWorkingDataSourceWithNoScripts(DataSource dataSource) throws SQLException { + // Verify the DataSource is functional by executing a simple query. + try (var conn = dataSource.getConnection(); + var stmt = conn.createStatement(); + var rs = stmt.executeQuery("SELECT 1")) { + assertTrue(rs.next()); + } + } + + @Test + void shouldProvideWorkingOrmTemplateWithNoScripts(ORMTemplate orm) { + // Verify ORMTemplate can execute queries on the empty database. + var result = orm.query("SELECT 42 AS result").getSingleResult(int.class); + assertEquals(42, result); + } +} From 2b28c63f3a1deb9335bc2ffa83a10598eab03f0f Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Tue, 3 Mar 2026 08:44:10 +0100 Subject: [PATCH 2/3] Add test cases. Update documentation. Relates to #60 --- docs/comparison.md | 2 +- docs/entities.md | 2 +- docs/faq.md | 6 +- docs/glossary.md | 2 +- docs/hydration.md | 10 +- docs/index.md | 3 +- docs/refs.md | 4 +- docs/repositories.md | 4 +- docs/validation.md | 2 +- .../core/template/impl/SchemaValidator.java | 8 + storm-foundation/pom.xml | 5 + .../java/st/orm/AbstractKeyMetamodelTest.java | 95 +++ .../java/st/orm/AbstractMetamodelTest.java | 234 ++++++ .../test/java/st/orm/DefaultJoinTypeTest.java | 70 ++ .../test/java/st/orm/EntityCallbackTest.java | 79 ++ .../src/test/java/st/orm/JoinTypeTest.java | 50 ++ .../src/test/java/st/orm/KeyDelegateTest.java | 183 ++++ .../src/test/java/st/orm/OperatorTest.java | 200 +++++ .../src/test/java/st/orm/RefTest.java | 198 +++++ .../src/test/java/st/orm/SliceTest.java | 54 ++ .../src/test/java/st/orm/StormConfigTest.java | 47 ++ .../java/st/orm/mapping/AnnotationsTest.java | 128 +++ .../java/st/orm/mapping/NameResolverTest.java | 64 ++ .../java/st/orm/mapping/RecordFieldTest.java | 116 +++ .../java/st/orm/mapping/RecordTypeTest.java | 149 ++++ .../java/st/orm/mapping/ResolverTest.java | 88 ++ .../JsonORMConverterIntegrationTest.java | 31 + .../st/orm/jackson/JsonORMConverterTest.java | 247 ++++++ .../java/st/orm/jackson/StormModuleTest.java | 767 +++++++++++++++++ .../JsonORMConverterIntegrationTest.java | 175 ++++ .../st/orm/jackson/JsonORMConverterTest.java | 221 +++++ .../java/st/orm/jackson/StormModuleTest.java | 792 ++++++++++++++++++ .../kotlin/st/orm/spring/CoverageBoostTest.kt | 561 +++++++++++++ .../orm/spring/NullBeanNameRepositoryTest.kt | 40 + ...RepositoryAutowireCandidateResolverTest.kt | 53 ++ .../st/orm/template/CoverageBoostTest.kt | 789 +++++++++++++++++ .../kotlin/st/orm/template/TemplatesTest.kt | 56 ++ .../mariadb/MariaDBEntityRepositoryTest.java | 47 ++ 38 files changed, 5562 insertions(+), 20 deletions(-) create mode 100644 storm-foundation/src/test/java/st/orm/AbstractKeyMetamodelTest.java create mode 100644 storm-foundation/src/test/java/st/orm/AbstractMetamodelTest.java create mode 100644 storm-foundation/src/test/java/st/orm/DefaultJoinTypeTest.java create mode 100644 storm-foundation/src/test/java/st/orm/EntityCallbackTest.java create mode 100644 storm-foundation/src/test/java/st/orm/JoinTypeTest.java create mode 100644 storm-foundation/src/test/java/st/orm/KeyDelegateTest.java create mode 100644 storm-foundation/src/test/java/st/orm/OperatorTest.java create mode 100644 storm-foundation/src/test/java/st/orm/RefTest.java create mode 100644 storm-foundation/src/test/java/st/orm/SliceTest.java create mode 100644 storm-foundation/src/test/java/st/orm/StormConfigTest.java create mode 100644 storm-foundation/src/test/java/st/orm/mapping/AnnotationsTest.java create mode 100644 storm-foundation/src/test/java/st/orm/mapping/NameResolverTest.java create mode 100644 storm-foundation/src/test/java/st/orm/mapping/RecordFieldTest.java create mode 100644 storm-foundation/src/test/java/st/orm/mapping/RecordTypeTest.java create mode 100644 storm-foundation/src/test/java/st/orm/mapping/ResolverTest.java create mode 100644 storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterTest.java create mode 100644 storm-jackson2/src/test/java/st/orm/jackson/StormModuleTest.java create mode 100644 storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterTest.java create mode 100644 storm-jackson3/src/test/java/st/orm/jackson/StormModuleTest.java create mode 100644 storm-kotlin-spring/src/test/kotlin/st/orm/spring/CoverageBoostTest.kt create mode 100644 storm-kotlin-spring/src/test/kotlin/st/orm/spring/NullBeanNameRepositoryTest.kt create mode 100644 storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryAutowireCandidateResolverTest.kt create mode 100644 storm-kotlin/src/test/kotlin/st/orm/template/CoverageBoostTest.kt diff --git a/docs/comparison.md b/docs/comparison.md index 2701664a4..5ab4bb233 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -3,7 +3,7 @@ import TabItem from '@theme/TabItem'; # Storm vs Other Frameworks -There is no universally "best" database framework. Each has strengths suited to different situations, team preferences, and project requirements. Teams approach data access differently, including using frameworks at various abstraction levels or even plain SQL. This page provides an honest comparison to help you evaluate whether Storm fits your needs, particularly if you value explicit and predictable behavior and fast development. We encourage you to explore the linked documentation for each framework and form your own conclusions. +There is no universally "best" database framework. Each has strengths suited to different situations, team preferences, and project requirements. Teams approach data access differently, including using frameworks at various abstraction levels or even plain SQL. This page provides a comparison to help you evaluate whether Storm fits your needs, particularly if you value explicit and predictable behavior and fast development. We encourage you to explore the linked documentation for each framework and form your own conclusions. ## Feature Comparison diff --git a/docs/entities.md b/docs/entities.md index 1bbc6a003..257964637 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -452,7 +452,7 @@ In this example, the `owner` and `city` foreign keys define the actual persisted ## Foreign Keys -The `@FK` annotation marks a field as a foreign key reference to another entity. Storm uses these annotations to automatically generate JOINs when querying and to derive column names (by default, appending `_id` to the field name). +The `@FK` annotation marks a field as a foreign key reference to another table-backed type (entity, projection, or data class with a `@PK`). Storm uses these annotations to automatically generate JOINs when querying and to derive column names (by default, appending `_id` to the field name). diff --git a/docs/faq.md b/docs/faq.md index 605d00da9..30537c44b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -24,7 +24,7 @@ Yes. Storm is used in production environments and follows semantic versioning fo ### Does Storm support schema validation? -Yes. Storm can validate your entity definitions against the actual database schema, catching mismatches like missing tables, missing columns, type incompatibilities, nullability differences, primary key mismatches, and missing sequences. This works similarly to Hibernate's `ddl-auto=validate`, but Storm never modifies the schema. +Yes. Storm can validate your entity definitions against the actual database schema, catching mismatches like missing tables, missing columns, type incompatibilities, type narrowing (potential precision loss), nullability differences, primary key mismatches, missing sequences, missing unique constraints, and missing foreign key constraints. This works similarly to Hibernate's `ddl-auto=validate`, but Storm never modifies the schema. Enable it in Spring Boot: @@ -73,10 +73,6 @@ Storm does not use bytecode manipulation or runtime proxies to intercept field a Storm maintains only a transaction-scoped entity cache for identity guarantees and dirty checking. There is no cross-transaction or application-wide cache. This avoids cache invalidation complexity, stale data bugs, and the configuration burden of managing cache regions. For caching reference data or frequently-read entities, use Spring's `@Cacheable` annotation or a dedicated caching layer (Redis, Caffeine) at the service level, where cache scope and invalidation strategy are explicit. -### No Distributed Transactions - -Storm works with single-datasource JDBC transactions. You can manage transactions using Storm's own `transaction { }` block or Spring's transaction infrastructure. For distributed transactions, configure Spring's `JtaTransactionManager` with a JTA provider (Atomikos, Narayana) and an XA-capable DataSource. Storm will participate in the distributed transaction automatically through Spring's transaction support. - ### No Bytecode Manipulation Storm does not enhance, instrument, or proxy your entity classes at build time or runtime. Entities are plain Kotlin data classes or Java records with no hidden behavior. The metamodel is generated at compile time by a KSP plugin (Kotlin) or annotation processor (Java), but this is standard code generation, not bytecode rewriting. diff --git a/docs/glossary.md b/docs/glossary.md index dad61db19..ffcdf0d42 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -35,7 +35,7 @@ The central entry point for all Storm database operations (`ORMTemplate`). Creat A read-only data class or record that implements the `Projection` interface. Projections represent database views or complex query results defined via `@ProjectionQuery`. Unlike entities, projections only support read operations. See [Projections](projections.md). **Ref** -A lightweight identifier (`Ref`) that carries only the entity type and primary key, deferring the loading of the full entity until `fetch()` is called. Using `Ref` instead of `City` in a foreign key field avoids the automatic JOIN, reducing query width when the related entity is not always needed. See [Refs](refs.md). +A lightweight identifier (`Ref`) that carries only the record type and primary key, deferring the loading of the full record until `fetch()` is called. Using `Ref` instead of `City` in a foreign key field avoids the automatic JOIN, reducing query width when the related data is not always needed. See [Refs](refs.md). **Repository** An interface that provides database access methods for an entity or projection type. `EntityRepository` offers built-in CRUD operations; `ProjectionRepository` offers read-only operations. Custom repositories extend these interfaces with domain-specific query methods. See [Repositories](repositories.md). diff --git a/docs/hydration.md b/docs/hydration.md index 0d4246716..cbe919fe5 100644 --- a/docs/hydration.md +++ b/docs/hydration.md @@ -260,7 +260,7 @@ Hydration reconstructs from the **innermost** level outward: ## Foreign Keys (@FK) -The `@FK` annotation marks a field as a foreign key relationship. When the result set includes a joined entity, Storm hydrates all its columns into the nested record. See [SQL Templates](sql-templates.md) for how `@FK` affects query generation. +The `@FK` annotation marks a field as a foreign key relationship. When the result set includes a joined table, Storm hydrates all its columns into the nested record. See [SQL Templates](sql-templates.md) for how `@FK` affects query generation. ### FK Column Layout @@ -312,7 +312,7 @@ When `city` is nullable and all city columns are NULL in a row, the hydrated `ci Eagerly loading every related entity is not always desirable. When a `User` references a `City`, which references a `Country`, a simple user query can cascade into loading the entire object graph. In many cases, the calling code only needs the foreign key value, not the full related entity. -A `Ref` is a lightweight reference that stores only the foreign key value, not the full entity. This gives you control over how much data is loaded during hydration. Use `Ref` when: +A `Ref` is a lightweight reference that stores only the foreign key value, not the full record. This gives you control over how much data is loaded during hydration. Use `Ref` when: - You need to break circular dependencies (self-referential entities like a tree structure) - You want to defer entity loading until the related data is actually needed - You are processing large result sets and want to minimize memory consumption @@ -598,15 +598,15 @@ If all columns for `address` are NULL, the field is set to `null`. If some colum |---------|-----------------| | **Simple field** | 1 column per field | | **Nested record** | Flattened: all nested fields become consecutive columns | -| **`@FK` entity** | All entity columns hydrated | -| **`@FK Ref`** | Only FK column hydrated (entity PK) | +| **`@FK` record** | All record columns hydrated | +| **`@FK Ref`** | Only FK column hydrated (record PK) | | **Composite PK** | Multiple columns for PK fields | | **Converter** | 1 column mapped to custom type | **Key principles:** - Columns map by **position**, not name - Nested records are **flattened** into consecutive columns -- `@FK` hydrates all columns from the related entity +- `@FK` hydrates all columns from the related record - `Ref` hydrates only the foreign key value - The interner ensures identity within a query result diff --git a/docs/index.md b/docs/index.md index 984382a93..bba5cc875 100644 --- a/docs/index.md +++ b/docs/index.md @@ -200,7 +200,7 @@ If you are new to Storm, follow these guides in order to build a solid foundatio If you are coming from JPA or Hibernate, these pages explain the key differences and how to transition: 1. [Migration from JPA](migration-from-jpa.md) -- annotation mapping, concept translation, coexistence strategy -2. [Storm vs Other Frameworks](comparison.md) -- honest feature comparison with JPA, jOOQ, MyBatis, and others +2. [Storm vs Other Frameworks](comparison.md) -- feature comparison with JPA, jOOQ, MyBatis, and others 3. [Entities](entities.md) -- how Storm entities differ from JPA entities 4. [Repositories](repositories.md) -- Storm repositories vs. Spring Data repositories 5. [Transactions](transactions.md) -- transaction management without an EntityManager @@ -222,7 +222,6 @@ If you are a tech lead or architect evaluating Storm for a production system, th Storm is focused on being a great ORM and SQL template engine. It intentionally does not include: - **Schema migration or DDL generation.** Storm does not create, alter, or drop tables. Use [Flyway](https://flywaydb.org/) or [Liquibase](https://www.liquibase.com/) for schema versioning and migrations. -- **Distributed transactions.** Storm works with single-datasource JDBC transactions. For distributed transactions, you can use Spring's transaction support. See the [FAQ](faq.md) for details. - **Second-level cache.** Storm's entity cache is transaction-scoped and cleared on commit. For cross-transaction caching, use Spring's `@Cacheable` or a dedicated cache layer like Caffeine or Redis. - **Lazy loading proxies.** Entities are plain records with no proxies. Related entities are loaded eagerly in a single query via JOINs. For deferred loading, use [Refs](refs.md) to explicitly control when related data is fetched. diff --git a/docs/refs.md b/docs/refs.md index bc91dfb47..5dae40255 100644 --- a/docs/refs.md +++ b/docs/refs.md @@ -3,13 +3,13 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -Refs are lightweight identifiers for entities that defer fetching until explicitly required. They optimize performance by avoiding unnecessary data retrieval and are useful for managing large object graphs. +Refs are lightweight identifiers for entities, projections, and other data types that defer fetching until explicitly required. They optimize performance by avoiding unnecessary data retrieval and are useful for managing large object graphs. --- ## Using Refs in Entities -To declare a relationship as a Ref, replace the entity type with `Ref` in the field declaration. Storm stores only the foreign key column value and does not generate a JOIN for the referenced table. This reduces the width of SELECT queries and avoids loading data you may never access. +To declare a relationship as a Ref, replace the direct type with `Ref` in the field declaration. Storm stores only the foreign key column value and does not generate a JOIN for the referenced table. This reduces the width of SELECT queries and avoids loading data you may never access. diff --git a/docs/repositories.md b/docs/repositories.md index 7cea27f86..a291b500b 100644 --- a/docs/repositories.md +++ b/docs/repositories.md @@ -306,7 +306,7 @@ For queries that need joins, projections, or more complex filtering, use the que ## Refs -Refs are lightweight identifiers that carry only the entity type and primary key. Selecting refs instead of full entities reduces memory usage and network bandwidth when you only need IDs for subsequent operations, such as batch lookups or filtering. See [Refs](refs.md) for a detailed discussion. +Refs are lightweight identifiers that carry only the record type and primary key. Selecting refs instead of full entities reduces memory usage and network bandwidth when you only need IDs for subsequent operations, such as batch lookups or filtering. See [Refs](refs.md) for a detailed discussion. @@ -322,7 +322,7 @@ val users: Flow = userRepository.selectByRef(refs) -Ref operations in Java return `Stream` objects that must be closed. Refs carry only the primary key and entity type, making them suitable for batch operations where loading full entities would be wasteful. +Ref operations in Java return `Stream` objects that must be closed. Refs carry only the primary key and record type, making them suitable for batch operations where loading full records would be wasteful. ```java // Select refs (lightweight identifiers) diff --git a/docs/validation.md b/docs/validation.md index 121800d63..6bb21ae0e 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -22,7 +22,7 @@ When Storm first encounters an entity or projection type, it inspects the record **Foreign key rules:** -- Fields annotated with `@FK` must be either an entity type or a `Ref` type. Scalars like `String` or `Integer` cannot be foreign keys. +- Fields annotated with `@FK` must be a `Data` type (entity, projection, or data class with a `@PK`) or a `Ref` wrapping such a type. Scalars like `String` or `Integer` cannot be foreign keys. - Auto-generated foreign keys (`@FK(generation = ...)`) cannot be inlined. **Inline component rules:** diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java index a57ac6bbd..23b20e9f9 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java @@ -16,6 +16,7 @@ package st.orm.core.template.impl; import static st.orm.core.spi.Providers.getSqlDialect; +import static st.orm.core.template.impl.RecordReflection.isPolymorphicData; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; @@ -545,6 +546,13 @@ private void validateForeignKeys( if (!Data.class.isAssignableFrom(targetType)) { continue; } + // Polymorphic FK: the target is a sealed Data interface whose subtypes are independent + // entities in separate tables. Standard DB foreign key constraints cannot express this + // (the FK id column can reference any of the target tables, determined by the + // discriminator column at runtime), so skip FK constraint validation for these fields. + if (isPolymorphicData(targetType)) { + continue; + } // Build a model for the target entity to get its table name. @SuppressWarnings("unchecked") Class targetDataType = (Class) targetType; diff --git a/storm-foundation/pom.xml b/storm-foundation/pom.xml index fcedcbe9d..802fb4152 100644 --- a/storm-foundation/pom.xml +++ b/storm-foundation/pom.xml @@ -65,5 +65,10 @@ jakarta.annotation jakarta.annotation-api + + org.junit.jupiter + junit-jupiter + test + diff --git a/storm-foundation/src/test/java/st/orm/AbstractKeyMetamodelTest.java b/storm-foundation/src/test/java/st/orm/AbstractKeyMetamodelTest.java new file mode 100644 index 000000000..d922decf8 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/AbstractKeyMetamodelTest.java @@ -0,0 +1,95 @@ +package st.orm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class AbstractKeyMetamodelTest { + + record TestData(int id) implements Data {} + + static class TestKeyMetamodel extends AbstractKeyMetamodel { + TestKeyMetamodel(Class fieldType) { + super(fieldType); + } + + TestKeyMetamodel(Class fieldType, String path) { + super(fieldType, path); + } + + TestKeyMetamodel(Class fieldType, String path, String field, boolean inline, Metamodel parent) { + super(fieldType, path, field, inline, parent); + } + + TestKeyMetamodel(Class fieldType, String path, String field, boolean inline, Metamodel parent, boolean isColumn) { + super(fieldType, path, field, inline, parent, isColumn); + } + + TestKeyMetamodel(Class fieldType, String path, String field, boolean inline, Metamodel parent, boolean isColumn, boolean nullable) { + super(fieldType, path, field, inline, parent, isColumn, nullable); + } + + @Override + public Object getValue(TestData record) { + return record.id(); + } + + @Override + public boolean isIdentical(TestData a, TestData b) { + return a == b; + } + + @Override + public boolean isSame(TestData a, TestData b) { + return a.id() == b.id(); + } + } + + @Test + void defaultConstructorNotNullable() { + TestKeyMetamodel metamodel = new TestKeyMetamodel(Integer.class); + assertFalse(metamodel.isNullable()); + assertEquals(Integer.class, metamodel.fieldType()); + } + + @Test + void pathConstructorNotNullable() { + TestKeyMetamodel metamodel = new TestKeyMetamodel(Integer.class, "path"); + assertFalse(metamodel.isNullable()); + assertEquals("path", metamodel.path()); + } + + @Test + void fiveArgConstructorNotNullable() { + TestKeyMetamodel metamodel = new TestKeyMetamodel(Integer.class, "path", "field", false, null); + assertFalse(metamodel.isNullable()); + assertEquals("field", metamodel.field()); + } + + @Test + void sixArgConstructorNotNullable() { + TestKeyMetamodel metamodel = new TestKeyMetamodel(Integer.class, "path", "field", false, null, true); + assertFalse(metamodel.isNullable()); + assertTrue(metamodel.isColumn()); + } + + @Test + void sevenArgConstructorWithNullableTrue() { + TestKeyMetamodel metamodel = new TestKeyMetamodel(Integer.class, "path", "field", false, null, true, true); + assertTrue(metamodel.isNullable()); + } + + @Test + void sevenArgConstructorWithNullableFalse() { + TestKeyMetamodel metamodel = new TestKeyMetamodel(Integer.class, "path", "field", false, null, true, false); + assertFalse(metamodel.isNullable()); + } + + @Test + void keyMetamodelImplementsKeyInterface() { + TestKeyMetamodel metamodel = new TestKeyMetamodel(Integer.class); + assertTrue(metamodel instanceof Metamodel.Key); + } +} diff --git a/storm-foundation/src/test/java/st/orm/AbstractMetamodelTest.java b/storm-foundation/src/test/java/st/orm/AbstractMetamodelTest.java new file mode 100644 index 000000000..cd2e12ea8 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/AbstractMetamodelTest.java @@ -0,0 +1,234 @@ +package st.orm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class AbstractMetamodelTest { + + record TestData(int id) implements Data {} + record InlineData(String value) implements Data {} + + static class TestMetamodel extends AbstractMetamodel { + TestMetamodel(Class fieldType) { + super(fieldType); + } + + TestMetamodel(Class fieldType, String path) { + super(fieldType, path); + } + + @Override + public Object getValue(TestData record) { + return record; + } + + @Override + public boolean isIdentical(TestData a, TestData b) { + return a == b; + } + + @Override + public boolean isSame(TestData a, TestData b) { + return a.equals(b); + } + } + + static class FieldMetamodel extends AbstractMetamodel { + FieldMetamodel(String path, String field, boolean inline, Metamodel parent) { + super(Integer.class, path, field, inline, parent); + } + + @Override + public Object getValue(TestData record) { + return record.id(); + } + + @Override + public boolean isIdentical(TestData a, TestData b) { + return a == b; + } + + @Override + public boolean isSame(TestData a, TestData b) { + return a.id() == b.id(); + } + } + + static class InlineMetamodel extends AbstractMetamodel { + InlineMetamodel(String path, String field, Metamodel parent) { + super(InlineData.class, path, field, true, parent); + } + + @Override + public Object getValue(TestData record) { + return null; + } + + @Override + public boolean isIdentical(TestData a, TestData b) { + return false; + } + + @Override + public boolean isSame(TestData a, TestData b) { + return false; + } + } + + @Test + void rootMetamodelConstructor() { + TestMetamodel metamodel = new TestMetamodel(TestData.class); + assertEquals(TestData.class, metamodel.fieldType()); + assertEquals("", metamodel.path()); + assertEquals("", metamodel.field()); + assertFalse(metamodel.isInline()); + assertFalse(metamodel.isColumn()); + } + + @Test + void pathMetamodelConstructor() { + TestMetamodel metamodel = new TestMetamodel(TestData.class, "table_path"); + assertEquals(TestData.class, metamodel.fieldType()); + assertEquals("table_path", metamodel.path()); + assertEquals("", metamodel.field()); + assertFalse(metamodel.isInline()); + assertTrue(metamodel.isColumn()); + } + + @Test + void rootReturnsFieldTypeForRootMetamodel() { + TestMetamodel metamodel = new TestMetamodel(TestData.class); + assertEquals(TestData.class, metamodel.root()); + } + + @Test + void rootReturnsParentRootForChildMetamodel() { + TestMetamodel parent = new TestMetamodel(TestData.class); + FieldMetamodel child = new FieldMetamodel("path", "id", false, parent); + assertEquals(TestData.class, child.root()); + } + + @Test + void tableReturnsParentForNonInlineChild() { + TestMetamodel parent = new TestMetamodel(TestData.class); + FieldMetamodel child = new FieldMetamodel("path", "id", false, parent); + assertSame(parent, child.table()); + } + + @Test + void tableReturnsSelfForRootMetamodel() { + TestMetamodel metamodel = new TestMetamodel(TestData.class); + assertSame(metamodel, metamodel.table()); + } + + @Test + void tableTraversesInlineParentsToFindTable() { + TestMetamodel root = new TestMetamodel(TestData.class); + InlineMetamodel inlineParent = new InlineMetamodel("path", "inlineField", root); + FieldMetamodel child = new FieldMetamodel("path", "nestedField", false, inlineParent); + assertSame(root, child.table()); + } + + @Test + void isColumnForFieldWithNonEmptyField() { + TestMetamodel parent = new TestMetamodel(TestData.class); + FieldMetamodel child = new FieldMetamodel("path", "id", false, parent); + assertTrue(child.isColumn()); + } + + @Test + void isColumnFalseForInline() { + TestMetamodel parent = new TestMetamodel(TestData.class); + InlineMetamodel inline = new InlineMetamodel("path", "inlineField", parent); + assertFalse(inline.isColumn()); + } + + @Test + void isInline() { + TestMetamodel parent = new TestMetamodel(TestData.class); + InlineMetamodel inline = new InlineMetamodel("path", "inlineField", parent); + assertTrue(inline.isInline()); + } + + @Test + void equalsBasedOnTableFieldTypePathAndField() { + TestMetamodel parent = new TestMetamodel(TestData.class); + FieldMetamodel field1 = new FieldMetamodel("path", "id", false, parent); + FieldMetamodel field2 = new FieldMetamodel("path", "id", false, parent); + assertEquals(field1, field2); + assertEquals(field1.hashCode(), field2.hashCode()); + } + + @Test + void notEqualWithDifferentField() { + TestMetamodel parent = new TestMetamodel(TestData.class); + FieldMetamodel field1 = new FieldMetamodel("path", "id", false, parent); + FieldMetamodel field2 = new FieldMetamodel("path", "name", false, parent); + assertNotEquals(field1, field2); + } + + @Test + void notEqualWithDifferentPath() { + TestMetamodel parent = new TestMetamodel(TestData.class); + FieldMetamodel field1 = new FieldMetamodel("path1", "id", false, parent); + FieldMetamodel field2 = new FieldMetamodel("path2", "id", false, parent); + assertNotEquals(field1, field2); + } + + @Test + void equalsItself() { + TestMetamodel metamodel = new TestMetamodel(TestData.class); + assertEquals(metamodel, metamodel); + } + + @Test + void notEqualToNonMetamodel() { + TestMetamodel metamodel = new TestMetamodel(TestData.class); + assertNotEquals(metamodel, "not a metamodel"); + } + + @Test + void toStringContainsRelevantInfo() { + TestMetamodel metamodel = new TestMetamodel(TestData.class); + String result = metamodel.toString(); + assertTrue(result.contains("TestData")); + assertTrue(result.contains("Metamodel")); + } + + @Test + void fieldPathWithBothPathAndField() { + TestMetamodel parent = new TestMetamodel(TestData.class); + FieldMetamodel child = new FieldMetamodel("entity", "id", false, parent); + assertEquals("entity.id", child.fieldPath()); + } + + @Test + void fieldPathWithOnlyPath() { + TestMetamodel metamodel = new TestMetamodel(TestData.class, "entity"); + assertEquals("entity", metamodel.fieldPath()); + } + + @Test + void fieldPathWithOnlyField() { + TestMetamodel parent = new TestMetamodel(TestData.class); + FieldMetamodel child = new FieldMetamodel("", "id", false, parent); + assertEquals("id", child.fieldPath()); + } + + @Test + void fieldPathEmptyForRoot() { + TestMetamodel metamodel = new TestMetamodel(TestData.class); + assertEquals("", metamodel.fieldPath()); + } + + @Test + void tableTypeReturnsFieldTypeOfTable() { + TestMetamodel metamodel = new TestMetamodel(TestData.class); + assertEquals(TestData.class, metamodel.tableType()); + } +} diff --git a/storm-foundation/src/test/java/st/orm/DefaultJoinTypeTest.java b/storm-foundation/src/test/java/st/orm/DefaultJoinTypeTest.java new file mode 100644 index 000000000..58f5215a3 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/DefaultJoinTypeTest.java @@ -0,0 +1,70 @@ +package st.orm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class DefaultJoinTypeTest { + + @Test + void innerJoinSql() { + assertEquals("INNER JOIN", DefaultJoinType.INNER.sql()); + } + + @Test + void innerJoinHasOnClause() { + assertTrue(DefaultJoinType.INNER.hasOnClause()); + } + + @Test + void innerJoinIsNotOuter() { + assertFalse(DefaultJoinType.INNER.isOuter()); + } + + @Test + void crossJoinSql() { + assertEquals("CROSS JOIN", DefaultJoinType.CROSS.sql()); + } + + @Test + void crossJoinHasNoOnClause() { + assertFalse(DefaultJoinType.CROSS.hasOnClause()); + } + + @Test + void crossJoinIsNotOuter() { + assertFalse(DefaultJoinType.CROSS.isOuter()); + } + + @Test + void leftJoinSql() { + assertEquals("LEFT JOIN", DefaultJoinType.LEFT.sql()); + } + + @Test + void leftJoinHasOnClause() { + assertTrue(DefaultJoinType.LEFT.hasOnClause()); + } + + @Test + void leftJoinIsOuter() { + assertTrue(DefaultJoinType.LEFT.isOuter()); + } + + @Test + void rightJoinSql() { + assertEquals("RIGHT JOIN", DefaultJoinType.RIGHT.sql()); + } + + @Test + void rightJoinHasOnClause() { + assertTrue(DefaultJoinType.RIGHT.hasOnClause()); + } + + @Test + void rightJoinIsOuter() { + assertTrue(DefaultJoinType.RIGHT.isOuter()); + } +} diff --git a/storm-foundation/src/test/java/st/orm/EntityCallbackTest.java b/storm-foundation/src/test/java/st/orm/EntityCallbackTest.java new file mode 100644 index 000000000..4c8161528 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/EntityCallbackTest.java @@ -0,0 +1,79 @@ +package st.orm; + +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; + +class EntityCallbackTest { + + record TestEntity(Integer id) implements Entity { + @Override + public Integer id() { + return id; + } + } + + @Test + void defaultBeforeInsertReturnsEntity() { + EntityCallback callback = new EntityCallback<>() {}; + TestEntity entity = new TestEntity(1); + assertSame(entity, callback.beforeInsert(entity)); + } + + @Test + void defaultBeforeUpdateReturnsEntity() { + EntityCallback callback = new EntityCallback<>() {}; + TestEntity entity = new TestEntity(1); + assertSame(entity, callback.beforeUpdate(entity)); + } + + @Test + void defaultBeforeUpsertDelegatesToBeforeInsert() { + TestEntity transformed = new TestEntity(99); + EntityCallback callback = new EntityCallback<>() { + @Override + public TestEntity beforeInsert(TestEntity entity) { + return transformed; + } + }; + TestEntity entity = new TestEntity(1); + assertSame(transformed, callback.beforeUpsert(entity)); + } + + @Test + void defaultAfterInsertDoesNotThrow() { + EntityCallback callback = new EntityCallback<>() {}; + callback.afterInsert(new TestEntity(1)); + } + + @Test + void defaultAfterUpdateDoesNotThrow() { + EntityCallback callback = new EntityCallback<>() {}; + callback.afterUpdate(new TestEntity(1)); + } + + @Test + void defaultAfterUpsertDelegatesToAfterInsert() { + boolean[] called = {false}; + EntityCallback callback = new EntityCallback<>() { + @Override + public void afterInsert(TestEntity entity) { + called[0] = true; + } + }; + callback.afterUpsert(new TestEntity(1)); + org.junit.jupiter.api.Assertions.assertTrue(called[0]); + } + + @Test + void defaultBeforeDeleteDoesNotThrow() { + EntityCallback callback = new EntityCallback<>() {}; + callback.beforeDelete(new TestEntity(1)); + } + + @Test + void defaultAfterDeleteDoesNotThrow() { + EntityCallback callback = new EntityCallback<>() {}; + callback.afterDelete(new TestEntity(1)); + } +} diff --git a/storm-foundation/src/test/java/st/orm/JoinTypeTest.java b/storm-foundation/src/test/java/st/orm/JoinTypeTest.java new file mode 100644 index 000000000..f55b7bd1a --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/JoinTypeTest.java @@ -0,0 +1,50 @@ +package st.orm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class JoinTypeTest { + + @Test + void innerStaticMethod() { + JoinType joinType = JoinType.inner(); + assertEquals("INNER JOIN", joinType.sql()); + assertTrue(joinType.hasOnClause()); + assertFalse(joinType.isOuter()); + } + + @Test + void crossStaticMethod() { + JoinType joinType = JoinType.cross(); + assertEquals("CROSS JOIN", joinType.sql()); + } + + @Test + void leftStaticMethod() { + JoinType joinType = JoinType.left(); + assertEquals("LEFT JOIN", joinType.sql()); + assertTrue(joinType.isOuter()); + } + + @Test + void rightStaticMethod() { + JoinType joinType = JoinType.right(); + assertEquals("RIGHT JOIN", joinType.sql()); + assertTrue(joinType.isOuter()); + } + + @Test + void defaultHasOnClauseIsTrue() { + JoinType custom = () -> "CUSTOM JOIN"; + assertTrue(custom.hasOnClause()); + } + + @Test + void defaultIsOuterIsFalse() { + JoinType custom = () -> "CUSTOM JOIN"; + assertFalse(custom.isOuter()); + } +} diff --git a/storm-foundation/src/test/java/st/orm/KeyDelegateTest.java b/storm-foundation/src/test/java/st/orm/KeyDelegateTest.java new file mode 100644 index 000000000..5e26c4834 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/KeyDelegateTest.java @@ -0,0 +1,183 @@ +package st.orm; + +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class KeyDelegateTest { + + record TestData(int id) implements Data {} + + static class TestMetamodel extends AbstractMetamodel { + TestMetamodel() { + super(Integer.class, "path", "id", false, null); + } + + @Override + public Object getValue(TestData record) { + return record.id(); + } + + @Override + public boolean isIdentical(TestData a, TestData b) { + return a == b; + } + + @Override + public boolean isSame(TestData a, TestData b) { + return a.id() == b.id(); + } + } + + static class TestKeyMetamodel extends AbstractKeyMetamodel { + private final boolean nullable; + + TestKeyMetamodel(boolean nullable) { + super(Integer.class, "path", "id", false, null, true, nullable); + this.nullable = nullable; + } + + @Override + public boolean isNullable() { + return nullable; + } + + @Override + public Object getValue(TestData record) { + return record.id(); + } + + @Override + public boolean isIdentical(TestData a, TestData b) { + return a == b; + } + + @Override + public boolean isSame(TestData a, TestData b) { + return a.id() == b.id(); + } + } + + @Test + void keyDelegateWrapsNonKeyMetamodel() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.Key key = Metamodel.key(metamodel); + assertNotNull(key); + assertTrue(key instanceof Metamodel.KeyDelegate); + } + + @Test + void keyReturnsExistingKeyInstance() { + TestKeyMetamodel keyMetamodel = new TestKeyMetamodel(false); + Metamodel.Key key = Metamodel.key(keyMetamodel); + assertSame(keyMetamodel, key); + } + + @Test + void keyDelegateDelegatesAllMethods() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.Key key = Metamodel.key(metamodel); + assertEquals(metamodel.isColumn(), key.isColumn()); + assertEquals(metamodel.isInline(), key.isInline()); + assertEquals(metamodel.root(), key.root()); + assertEquals(metamodel.table(), key.table()); + assertEquals(metamodel.path(), key.path()); + assertEquals(metamodel.fieldType(), key.fieldType()); + assertEquals(metamodel.field(), key.field()); + } + + @Test + void keyDelegateIsNotNullableForNonKeyMetamodel() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.Key key = Metamodel.key(metamodel); + assertFalse(key.isNullable()); + } + + @Test + void keyDelegateIsNullableWhenDelegateIsNullableKey() { + TestKeyMetamodel nullableKey = new TestKeyMetamodel(true); + Metamodel.KeyDelegate delegate = new Metamodel.KeyDelegate<>(nullableKey); + assertTrue(delegate.isNullable()); + } + + @Test + void keyDelegateEqualsWrappedMetamodel() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.KeyDelegate delegate = new Metamodel.KeyDelegate<>(metamodel); + assertEquals(delegate, metamodel); + assertEquals(metamodel, delegate); + } + + @Test + void keyDelegateEqualsAnotherDelegateWrappingSameMetamodel() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.KeyDelegate delegate1 = new Metamodel.KeyDelegate<>(metamodel); + Metamodel.KeyDelegate delegate2 = new Metamodel.KeyDelegate<>(metamodel); + assertEquals(delegate1, delegate2); + } + + @Test + void keyDelegateHashCodeMatchesWrappedMetamodel() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.KeyDelegate delegate = new Metamodel.KeyDelegate<>(metamodel); + assertEquals(metamodel.hashCode(), delegate.hashCode()); + } + + @Test + void keyDelegateToStringDelegatesToWrapped() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.KeyDelegate delegate = new Metamodel.KeyDelegate<>(metamodel); + assertEquals(metamodel.toString(), delegate.toString()); + } + + @Test + void keyDelegateGetValueDelegatesToWrapped() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.Key key = Metamodel.key(metamodel); + TestData data = new TestData(42); + assertEquals(42, key.getValue(data)); + } + + @Test + void keyDelegateIsIdenticalDelegatesToWrapped() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.Key key = Metamodel.key(metamodel); + TestData dataA = new TestData(1); + TestData dataB = new TestData(1); + assertFalse(key.isIdentical(dataA, dataB)); + assertTrue(key.isIdentical(dataA, dataA)); + } + + @Test + void keyDelegateIsSameDelegatesToWrapped() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.Key key = Metamodel.key(metamodel); + TestData dataA = new TestData(1); + TestData dataB = new TestData(1); + TestData dataC = new TestData(2); + assertTrue(key.isSame(dataA, dataB)); + assertFalse(key.isSame(dataA, dataC)); + } + + @Test + void keyRejectsNullMetamodel() { + assertThrows(NullPointerException.class, () -> Metamodel.key(null)); + } + + @Test + void keyDelegateConstructorRejectsNull() { + assertThrows(NullPointerException.class, () -> new Metamodel.KeyDelegate<>(null)); + } + + @Test + void keyDelegateEqualsItself() { + TestMetamodel metamodel = new TestMetamodel(); + Metamodel.KeyDelegate delegate = new Metamodel.KeyDelegate<>(metamodel); + assertEquals(delegate, delegate); + } +} diff --git a/storm-foundation/src/test/java/st/orm/OperatorTest.java b/storm-foundation/src/test/java/st/orm/OperatorTest.java new file mode 100644 index 000000000..2964fc048 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/OperatorTest.java @@ -0,0 +1,200 @@ +package st.orm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class OperatorTest { + + @Test + void inOperatorWithZeroPlaceholders() { + String result = Operator.IN.format("col"); + assertEquals("1 <> 1", result); + } + + @Test + void inOperatorWithSinglePlaceholder() { + String result = Operator.IN.format("col", "?"); + assertEquals("col IN (?)", result); + } + + @Test + void inOperatorWithMultiplePlaceholders() { + String result = Operator.IN.format("col", "?", "?", "?"); + assertEquals("col IN (?, ?, ?)", result); + } + + @Test + void notInOperatorWithZeroPlaceholders() { + String result = Operator.NOT_IN.format("col"); + assertEquals("1 = 1", result); + } + + @Test + void notInOperatorWithSinglePlaceholder() { + String result = Operator.NOT_IN.format("col", "?"); + assertEquals("col NOT IN (?)", result); + } + + @Test + void notInOperatorWithMultiplePlaceholders() { + String result = Operator.NOT_IN.format("col", "?", "?"); + assertEquals("col NOT IN (?, ?)", result); + } + + @Test + void equalsOperator() { + String result = Operator.EQUALS.format("col", "?"); + assertEquals("col = ?", result); + } + + @Test + void equalsOperatorRequiresOnePlaceholder() { + assertThrows(IllegalArgumentException.class, () -> Operator.EQUALS.format("col", "?", "?")); + } + + @Test + void equalsOperatorRequiresAtLeastOnePlaceholder() { + assertThrows(IllegalArgumentException.class, () -> Operator.EQUALS.format("col")); + } + + @Test + void notEqualsOperator() { + String result = Operator.NOT_EQUALS.format("col", "?"); + assertEquals("col <> ?", result); + } + + @Test + void notEqualsOperatorRequiresOnePlaceholder() { + assertThrows(IllegalArgumentException.class, () -> Operator.NOT_EQUALS.format("col", "?", "?")); + } + + @Test + void likeOperator() { + String result = Operator.LIKE.format("col", "?"); + assertEquals("col LIKE ?", result); + } + + @Test + void likeOperatorRequiresOnePlaceholder() { + assertThrows(IllegalArgumentException.class, () -> Operator.LIKE.format("col", "?", "?")); + } + + @Test + void notLikeOperator() { + String result = Operator.NOT_LIKE.format("col", "?"); + assertEquals("col NOT LIKE ?", result); + } + + @Test + void notLikeOperatorRequiresOnePlaceholder() { + assertThrows(IllegalArgumentException.class, () -> Operator.NOT_LIKE.format("col", "?", "?")); + } + + @Test + void greaterThanOperator() { + String result = Operator.GREATER_THAN.format("col", "?"); + assertEquals("col > ?", result); + } + + @Test + void greaterThanOperatorRequiresOnePlaceholder() { + assertThrows(IllegalArgumentException.class, () -> Operator.GREATER_THAN.format("col")); + } + + @Test + void greaterThanOrEqualOperator() { + String result = Operator.GREATER_THAN_OR_EQUAL.format("col", "?"); + assertEquals("col >= ?", result); + } + + @Test + void greaterThanOrEqualOperatorRequiresOnePlaceholder() { + assertThrows(IllegalArgumentException.class, () -> Operator.GREATER_THAN_OR_EQUAL.format("col")); + } + + @Test + void lessThanOperator() { + String result = Operator.LESS_THAN.format("col", "?"); + assertEquals("col < ?", result); + } + + @Test + void lessThanOperatorRequiresOnePlaceholder() { + assertThrows(IllegalArgumentException.class, () -> Operator.LESS_THAN.format("col")); + } + + @Test + void lessThanOrEqualOperator() { + String result = Operator.LESS_THAN_OR_EQUAL.format("col", "?"); + assertEquals("col <= ?", result); + } + + @Test + void lessThanOrEqualOperatorRequiresOnePlaceholder() { + assertThrows(IllegalArgumentException.class, () -> Operator.LESS_THAN_OR_EQUAL.format("col")); + } + + @Test + void betweenOperator() { + String result = Operator.BETWEEN.format("col", "?", "?"); + assertEquals("col BETWEEN ? AND ?", result); + } + + @Test + void betweenOperatorRequiresTwoPlaceholders() { + assertThrows(IllegalArgumentException.class, () -> Operator.BETWEEN.format("col", "?")); + } + + @Test + void isTrueOperator() { + String result = Operator.IS_TRUE.format("col"); + assertEquals("col IS TRUE", result); + } + + @Test + void isTrueOperatorRequiresZeroPlaceholders() { + assertThrows(IllegalArgumentException.class, () -> Operator.IS_TRUE.format("col", "?")); + } + + @Test + void isFalseOperator() { + String result = Operator.IS_FALSE.format("col"); + assertEquals("col IS FALSE", result); + } + + @Test + void isFalseOperatorRequiresZeroPlaceholders() { + assertThrows(IllegalArgumentException.class, () -> Operator.IS_FALSE.format("col", "?")); + } + + @Test + void isNullOperator() { + String result = Operator.IS_NULL.format("col"); + assertEquals("col IS NULL", result); + } + + @Test + void isNullOperatorRequiresZeroPlaceholders() { + assertThrows(IllegalArgumentException.class, () -> Operator.IS_NULL.format("col", "?")); + } + + @Test + void isNotNullOperator() { + String result = Operator.IS_NOT_NULL.format("col"); + assertEquals("col IS NOT NULL", result); + } + + @Test + void isNotNullOperatorRequiresZeroPlaceholders() { + assertThrows(IllegalArgumentException.class, () -> Operator.IS_NOT_NULL.format("col", "?")); + } + + @Test + void equalsOperatorWithNullColumn() { + // When column is null, the operator format still produces SQL with "null" as string + String result = Operator.EQUALS.format(null, "?"); + assertEquals("null = ?", result); + } +} diff --git a/storm-foundation/src/test/java/st/orm/RefTest.java b/storm-foundation/src/test/java/st/orm/RefTest.java new file mode 100644 index 000000000..dbf40db21 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/RefTest.java @@ -0,0 +1,198 @@ +package st.orm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class RefTest { + + record TestEntity(int id, String name) implements Data {} + + record SampleEntity(Integer id) implements Entity { + @Override + public Integer id() { + return id; + } + } + + record SampleProjection(String data) implements Projection {} + + @Test + void detachedRefOfTypeAndPk() { + Ref ref = Ref.of(TestEntity.class, 42); + assertEquals(TestEntity.class, ref.type()); + assertEquals(42, ref.id()); + assertNull(ref.getOrNull()); + assertNull(ref.fetchOrNull()); + assertFalse(ref.isFetchable()); + assertFalse(ref.isLoaded()); + } + + @Test + void detachedRefUnloadReturnsSameInstance() { + Ref ref = Ref.of(TestEntity.class, 42); + assertSame(ref, ref.unload()); + } + + @Test + void detachedRefFetchThrowsPersistenceException() { + Ref ref = Ref.of(TestEntity.class, 42); + assertThrows(PersistenceException.class, ref::fetch); + } + + @Test + void detachedRefToString() { + Ref ref = Ref.of(TestEntity.class, 42); + assertEquals("TestEntity@42", ref.toString()); + } + + @Test + void detachedRefEqualityBasedOnTypeAndId() { + Ref ref1 = Ref.of(TestEntity.class, 42); + Ref ref2 = Ref.of(TestEntity.class, 42); + assertEquals(ref1, ref2); + assertEquals(ref1.hashCode(), ref2.hashCode()); + } + + @Test + void detachedRefInequalityWithDifferentId() { + Ref ref1 = Ref.of(TestEntity.class, 1); + Ref ref2 = Ref.of(TestEntity.class, 2); + assertNotEquals(ref1, ref2); + } + + @Test + void detachedRefEqualsItself() { + Ref ref = Ref.of(TestEntity.class, 42); + assertEquals(ref, ref); + } + + @Test + void detachedRefNotEqualToNull() { + Ref ref = Ref.of(TestEntity.class, 42); + assertNotEquals(ref, null); + } + + @Test + void detachedRefNotEqualToNonRef() { + Ref ref = Ref.of(TestEntity.class, 42); + assertNotEquals(ref, "not a ref"); + } + + @Test + void ofTypeAndPkRejectsNullType() { + assertThrows(NullPointerException.class, () -> Ref.of((Class) null, 42)); + } + + @Test + void ofTypeAndPkRejectsNullPk() { + assertThrows(NullPointerException.class, () -> Ref.of(TestEntity.class, null)); + } + + @Test + void entityIdExtraction() { + SampleEntity entity = new SampleEntity(99); + Ref ref = Ref.of(entity); + Integer extractedId = Ref.entityId(ref); + assertEquals(99, extractedId); + } + + @Test + void projectionIdExtraction() { + SampleProjection projection = new SampleProjection("test"); + Ref ref = Ref.of(projection, 77); + Integer extractedId = Ref.projectionId(ref); + assertEquals(77, extractedId); + } + + @Test + void refOfEntityReturnsLoadedRef() { + SampleEntity entity = new SampleEntity(10); + Ref ref = Ref.of(entity); + assertEquals(SampleEntity.class, ref.type()); + assertEquals(10, ref.id()); + assertSame(entity, ref.getOrNull()); + assertSame(entity, ref.fetchOrNull()); + assertFalse(ref.isFetchable()); + assertTrue(ref.isLoaded()); + } + + @Test + void refOfEntityUnloadReturnsDetachedRef() { + SampleEntity entity = new SampleEntity(10); + Ref ref = Ref.of(entity); + Ref unloaded = ref.unload(); + assertNotNull(unloaded); + assertEquals(ref.id(), unloaded.id()); + assertNull(unloaded.getOrNull()); + assertFalse(unloaded.isLoaded()); + } + + @Test + void refOfEntityRejectsNullEntity() { + assertThrows(NullPointerException.class, () -> Ref.of((SampleEntity) null)); + } + + @Test + void refOfProjectionReturnsLoadedRef() { + SampleProjection projection = new SampleProjection("test"); + Ref ref = Ref.of(projection, 5); + assertEquals(SampleProjection.class, ref.type()); + assertEquals(5, ref.id()); + assertSame(projection, ref.getOrNull()); + assertSame(projection, ref.fetchOrNull()); + assertFalse(ref.isFetchable()); + assertTrue(ref.isLoaded()); + } + + @Test + void refOfProjectionUnloadReturnsDetachedRef() { + SampleProjection projection = new SampleProjection("test"); + Ref ref = Ref.of(projection, 5); + Ref unloaded = ref.unload(); + assertNotNull(unloaded); + assertEquals(5, unloaded.id()); + assertNull(unloaded.getOrNull()); + } + + @Test + void refOfProjectionRejectsNullProjection() { + assertThrows(NullPointerException.class, () -> Ref.of((SampleProjection) null, 5)); + } + + @Test + void refOfProjectionRejectsNullId() { + assertThrows(NullPointerException.class, () -> Ref.of(new SampleProjection("test"), null)); + } + + @Test + void refOfEntityFetchReturnsEntity() { + SampleEntity entity = new SampleEntity(10); + Ref ref = Ref.of(entity); + assertEquals(entity, ref.fetch()); + } + + @Test + void refOfProjectionFetchReturnsProjection() { + SampleProjection projection = new SampleProjection("data"); + Ref ref = Ref.of(projection, 1); + assertEquals(projection, ref.fetch()); + } + + @Test + void entityRefAndDetachedRefAreEqualByTypeAndId() { + SampleEntity entity = new SampleEntity(42); + Ref entityRef = Ref.of(entity); + Ref detachedRef = Ref.of(SampleEntity.class, 42); + assertEquals(entityRef, detachedRef); + assertEquals(detachedRef, entityRef); + assertEquals(entityRef.hashCode(), detachedRef.hashCode()); + } +} diff --git a/storm-foundation/src/test/java/st/orm/SliceTest.java b/storm-foundation/src/test/java/st/orm/SliceTest.java new file mode 100644 index 000000000..a36a51a87 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/SliceTest.java @@ -0,0 +1,54 @@ +package st.orm; + +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; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class SliceTest { + + @Test + void sliceWithContent() { + Slice slice = new Slice<>(List.of("a", "b", "c"), true); + assertEquals(3, slice.content().size()); + assertEquals("a", slice.content().get(0)); + assertTrue(slice.hasNext()); + } + + @Test + void sliceWithEmptyContent() { + Slice slice = new Slice<>(List.of(), false); + assertTrue(slice.content().isEmpty()); + assertFalse(slice.hasNext()); + } + + @Test + void sliceContentIsImmutableCopy() { + List original = new ArrayList<>(List.of("a", "b")); + Slice slice = new Slice<>(original, false); + original.add("c"); + assertEquals(2, slice.content().size()); + } + + @Test + void sliceContentIsUnmodifiable() { + Slice slice = new Slice<>(List.of("a"), false); + assertThrows(UnsupportedOperationException.class, () -> slice.content().add("b")); + } + + @Test + void sliceHasNextTrue() { + Slice slice = new Slice<>(List.of(1), true); + assertTrue(slice.hasNext()); + } + + @Test + void sliceHasNextFalse() { + Slice slice = new Slice<>(List.of(1), false); + assertFalse(slice.hasNext()); + } +} diff --git a/storm-foundation/src/test/java/st/orm/StormConfigTest.java b/storm-foundation/src/test/java/st/orm/StormConfigTest.java new file mode 100644 index 000000000..663366506 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/StormConfigTest.java @@ -0,0 +1,47 @@ +package st.orm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class StormConfigTest { + + @Test + void defaultsReturnsNonNull() { + StormConfig config = StormConfig.defaults(); + assertNotNull(config); + } + + @Test + void ofCreatesConfigWithProperties() { + StormConfig config = StormConfig.of(Map.of("key", "value")); + assertEquals("value", config.getProperty("key")); + } + + @Test + void getPropertyReturnsNullForMissingKey() { + StormConfig config = StormConfig.of(Map.of()); + assertNull(config.getProperty("nonexistent.key.that.should.not.exist")); + } + + @Test + void getPropertyWithDefaultReturnsDefaultForMissingKey() { + StormConfig config = StormConfig.of(Map.of()); + assertEquals("default", config.getProperty("nonexistent.key.that.should.not.exist", "default")); + } + + @Test + void getPropertyWithDefaultReturnsValueWhenPresent() { + StormConfig config = StormConfig.of(Map.of("key", "value")); + assertEquals("value", config.getProperty("key", "default")); + } + + @Test + void getPropertyFallsBackToSystemProperty() { + String javaVersion = StormConfig.defaults().getProperty("java.version"); + assertNotNull(javaVersion); + } +} diff --git a/storm-foundation/src/test/java/st/orm/mapping/AnnotationsTest.java b/storm-foundation/src/test/java/st/orm/mapping/AnnotationsTest.java new file mode 100644 index 000000000..3a3863f3a --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/mapping/AnnotationsTest.java @@ -0,0 +1,128 @@ +package st.orm.mapping; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.annotation.Annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AnnotationsTest { + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @interface TestAnnotation { + String value() default ""; + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @interface RepeatableItems { + RepeatableItem[] value(); + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @Repeatable(RepeatableItems.class) + @interface RepeatableItem { + String value(); + } + + @TestAnnotation("test") + record AnnotatedRecord(int id) {} + + @RepeatableItem("first") + @RepeatableItem("second") + record MultiAnnotatedRecord(int value) {} + + @RepeatableItem("single") + record SingleRepeatableRecord(int value) {} + + @Test + void isAnnotationPresentReturnsTrueForDirectAnnotation() { + List annotations = List.of(AnnotatedRecord.class.getAnnotations()); + assertTrue(Annotations.isAnnotationPresent(annotations, TestAnnotation.class)); + } + + @Test + void isAnnotationPresentReturnsFalseWhenAbsent() { + List annotations = List.of(); + assertFalse(Annotations.isAnnotationPresent(annotations, TestAnnotation.class)); + } + + @Test + void getAnnotationReturnsSingleAnnotation() { + List annotations = List.of(AnnotatedRecord.class.getAnnotations()); + TestAnnotation result = Annotations.getAnnotation(annotations, TestAnnotation.class); + assertNotNull(result); + assertEquals("test", result.value()); + } + + @Test + void getAnnotationReturnsNullWhenAbsent() { + List annotations = List.of(); + TestAnnotation result = Annotations.getAnnotation(annotations, TestAnnotation.class); + assertNull(result); + } + + @Test + void getAnnotationsReturnsEmptyArrayWhenAbsent() { + List annotations = List.of(); + TestAnnotation[] results = Annotations.getAnnotations(annotations, TestAnnotation.class); + assertNotNull(results); + assertEquals(0, results.length); + } + + @Test + void getAnnotationsReturnsDirectAnnotations() { + List annotations = List.of(AnnotatedRecord.class.getAnnotations()); + TestAnnotation[] results = Annotations.getAnnotations(annotations, TestAnnotation.class); + assertEquals(1, results.length); + } + + @Test + void getAnnotationsHandlesRepeatableAnnotations() { + List annotations = List.of(MultiAnnotatedRecord.class.getAnnotations()); + RepeatableItem[] results = Annotations.getAnnotations(annotations, RepeatableItem.class); + assertEquals(2, results.length); + assertEquals("first", results[0].value()); + assertEquals("second", results[1].value()); + } + + @Test + void getAnnotationReturnsNullForMultipleRepeatableAnnotations() { + List annotations = List.of(MultiAnnotatedRecord.class.getAnnotations()); + RepeatableItem result = Annotations.getAnnotation(annotations, RepeatableItem.class); + assertNull(result); + } + + @Test + void isAnnotationPresentReturnsFalseForMultipleRepeatableAnnotations() { + // isAnnotationPresent delegates to getAnnotation, which returns null when multiple are found + List annotations = List.of(MultiAnnotatedRecord.class.getAnnotations()); + assertFalse(Annotations.isAnnotationPresent(annotations, RepeatableItem.class)); + } + + @Test + void getAnnotationReturnsSingleRepeatableAnnotation() { + List annotations = List.of(SingleRepeatableRecord.class.getAnnotations()); + RepeatableItem result = Annotations.getAnnotation(annotations, RepeatableItem.class); + assertNotNull(result); + assertEquals("single", result.value()); + } + + @Test + void getAnnotationsForNonRepeatableReturnsEmpty() { + List annotations = List.of(); + TestAnnotation[] results = Annotations.getAnnotations(annotations, TestAnnotation.class); + assertEquals(0, results.length); + } +} diff --git a/storm-foundation/src/test/java/st/orm/mapping/NameResolverTest.java b/storm-foundation/src/test/java/st/orm/mapping/NameResolverTest.java new file mode 100644 index 000000000..b0f2cd45a --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/mapping/NameResolverTest.java @@ -0,0 +1,64 @@ +package st.orm.mapping; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class NameResolverTest { + + @Test + void simpleCamelCase() { + assertEquals("user_name", NameResolver.camelCaseToSnakeCase("userName")); + } + + @Test + void singleWord() { + assertEquals("name", NameResolver.camelCaseToSnakeCase("name")); + } + + @Test + void uppercaseLetters() { + assertEquals("first_name", NameResolver.camelCaseToSnakeCase("firstName")); + } + + @Test + void multipleUppercase() { + assertEquals("my_long_variable_name", NameResolver.camelCaseToSnakeCase("myLongVariableName")); + } + + @Test + void singleCharacter() { + assertEquals("a", NameResolver.camelCaseToSnakeCase("a")); + } + + @Test + void startsWithUppercase() { + assertEquals("user", NameResolver.camelCaseToSnakeCase("User")); + } + + @Test + void digitTransitionFromLowercase() { + assertEquals("survey_terminates_4w", NameResolver.camelCaseToSnakeCase("surveyTerminates4w")); + } + + @Test + void digitWithoutLowercasePrefix() { + assertEquals("a1b", NameResolver.camelCaseToSnakeCase("a1b")); + } + + @Test + void consecutiveDigits() { + assertEquals("abc_123", NameResolver.camelCaseToSnakeCase("abc123")); + } + + @Test + void digitAtStartDoesNotInsertUnderscore() { + // Only 1 char before digit, so no underscore + assertEquals("a1", NameResolver.camelCaseToSnakeCase("a1")); + } + + @Test + void twoLowercaseBeforeDigit() { + assertEquals("ab_1", NameResolver.camelCaseToSnakeCase("ab1")); + } +} diff --git a/storm-foundation/src/test/java/st/orm/mapping/RecordFieldTest.java b/storm-foundation/src/test/java/st/orm/mapping/RecordFieldTest.java new file mode 100644 index 000000000..866c74c18 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/mapping/RecordFieldTest.java @@ -0,0 +1,116 @@ +package st.orm.mapping; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.List; +import org.junit.jupiter.api.Test; +import st.orm.Data; +import st.orm.DbColumn; +import st.orm.PersistenceException; + +class RecordFieldTest { + + record TestData(int id, String name) implements Data {} + + record NonData(int value) {} + + private RecordField createField(Class declaringType, String name, Class type, + List annotations) throws NoSuchMethodException { + Method method = declaringType.getMethod(name); + return new RecordField(declaringType, name, type, type, false, false, method, annotations); + } + + @Test + void basicRecordFieldCreation() throws NoSuchMethodException { + RecordField field = createField(TestData.class, "id", int.class, List.of()); + assertEquals(TestData.class, field.declaringType()); + assertEquals("id", field.name()); + assertEquals(int.class, field.type()); + assertFalse(field.nullable()); + assertFalse(field.mutable()); + assertNotNull(field.method()); + assertTrue(field.annotations().isEmpty()); + } + + @Test + void isDataTypeReturnsTrueForDataClass() throws NoSuchMethodException { + Method method = TestData.class.getMethod("id"); + RecordField field = new RecordField(TestData.class, "nested", TestData.class, TestData.class, false, false, method, List.of()); + assertTrue(field.isDataType()); + } + + @Test + void isDataTypeReturnsFalseForNonDataClass() throws NoSuchMethodException { + RecordField field = createField(TestData.class, "id", int.class, List.of()); + assertFalse(field.isDataType()); + } + + @Test + void requireDataTypeReturnsClassForDataType() throws NoSuchMethodException { + Method method = TestData.class.getMethod("id"); + RecordField field = new RecordField(TestData.class, "nested", TestData.class, TestData.class, false, false, method, List.of()); + assertEquals(TestData.class, field.requireDataType()); + } + + @Test + void requireDataTypeThrowsForNonDataType() throws NoSuchMethodException { + RecordField field = createField(TestData.class, "id", int.class, List.of()); + assertThrows(PersistenceException.class, field::requireDataType); + } + + @Test + void annotationsAreImmutableCopy() throws NoSuchMethodException { + RecordField field = createField(TestData.class, "id", int.class, List.of()); + assertThrows(UnsupportedOperationException.class, () -> field.annotations().add(null)); + } + + @Test + void isAnnotationPresentFindsDirectAnnotation() throws NoSuchMethodException { + Annotation[] recordAnnotations = TestData.class.getRecordComponents()[0].getAnnotations(); + // TestData id field has no annotations, test the negative case + RecordField field = createField(TestData.class, "id", int.class, List.of()); + assertFalse(field.isAnnotationPresent(DbColumn.class)); + } + + @Test + void getAnnotationReturnsNullWhenNotPresent() throws NoSuchMethodException { + RecordField field = createField(TestData.class, "id", int.class, List.of()); + assertNull(field.getAnnotation(DbColumn.class)); + } + + @Test + void getAnnotationsReturnsEmptyArrayWhenNotPresent() throws NoSuchMethodException { + RecordField field = createField(TestData.class, "id", int.class, List.of()); + DbColumn[] annotations = field.getAnnotations(DbColumn.class); + assertNotNull(annotations); + assertEquals(0, annotations.length); + } + + @Test + void constructorRejectsNullDeclaringType() throws NoSuchMethodException { + Method method = TestData.class.getMethod("id"); + assertThrows(NullPointerException.class, () -> + new RecordField(null, "id", int.class, int.class, false, false, method, List.of())); + } + + @Test + void constructorRejectsNullName() throws NoSuchMethodException { + Method method = TestData.class.getMethod("id"); + assertThrows(NullPointerException.class, () -> + new RecordField(TestData.class, null, int.class, int.class, false, false, method, List.of())); + } + + @Test + void constructorRejectsNullType() throws NoSuchMethodException { + Method method = TestData.class.getMethod("id"); + assertThrows(NullPointerException.class, () -> + new RecordField(TestData.class, "id", null, int.class, false, false, method, List.of())); + } +} diff --git a/storm-foundation/src/test/java/st/orm/mapping/RecordTypeTest.java b/storm-foundation/src/test/java/st/orm/mapping/RecordTypeTest.java new file mode 100644 index 000000000..e3b74b62d --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/mapping/RecordTypeTest.java @@ -0,0 +1,149 @@ +package st.orm.mapping; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Constructor; +import java.util.List; +import org.junit.jupiter.api.Test; +import st.orm.Data; +import st.orm.DbColumn; +import st.orm.PersistenceException; + +class RecordTypeTest { + + record TestData(int id, String name) implements Data {} + + record NonData(int value) {} + + private RecordType createRecordType(Class type) throws NoSuchMethodException { + Constructor constructor = type.getDeclaredConstructors()[0]; + constructor.setAccessible(true); + return new RecordType(type, constructor, List.of(), List.of()); + } + + @Test + void basicRecordTypeCreation() throws NoSuchMethodException { + RecordType recordType = createRecordType(TestData.class); + assertEquals(TestData.class, recordType.type()); + assertNotNull(recordType.constructor()); + assertTrue(recordType.annotations().isEmpty()); + assertTrue(recordType.fields().isEmpty()); + } + + @Test + void isDataTypeReturnsTrueForDataClass() throws NoSuchMethodException { + RecordType recordType = createRecordType(TestData.class); + assertTrue(recordType.isDataType()); + } + + @Test + void isDataTypeReturnsFalseForNonDataClass() throws NoSuchMethodException { + RecordType recordType = createRecordType(NonData.class); + assertFalse(recordType.isDataType()); + } + + @Test + void requireDataTypeReturnsClassForDataType() throws NoSuchMethodException { + RecordType recordType = createRecordType(TestData.class); + assertEquals(TestData.class, recordType.requireDataType()); + } + + @Test + void requireDataTypeThrowsForNonDataType() throws NoSuchMethodException { + RecordType recordType = createRecordType(NonData.class); + assertThrows(PersistenceException.class, recordType::requireDataType); + } + + @Test + void newInstanceCreatesRecord() throws NoSuchMethodException { + RecordType recordType = createRecordType(TestData.class); + Object instance = recordType.newInstance(new Object[]{42, "test"}); + assertNotNull(instance); + assertTrue(instance instanceof TestData); + TestData data = (TestData) instance; + assertEquals(42, data.id()); + assertEquals("test", data.name()); + } + + @Test + void newInstanceThrowsOnWrongArgCount() throws NoSuchMethodException { + RecordType recordType = createRecordType(TestData.class); + assertThrows(PersistenceException.class, () -> recordType.newInstance(new Object[]{42})); + } + + @Test + void annotationsAreImmutableCopy() throws NoSuchMethodException { + RecordType recordType = createRecordType(TestData.class); + assertThrows(UnsupportedOperationException.class, () -> recordType.annotations().add(null)); + } + + @Test + void fieldsAreImmutableCopy() throws NoSuchMethodException { + RecordType recordType = createRecordType(TestData.class); + assertThrows(UnsupportedOperationException.class, () -> recordType.fields().add(null)); + } + + @Test + void isAnnotationPresentReturnsFalseWhenNoAnnotations() throws NoSuchMethodException { + RecordType recordType = createRecordType(TestData.class); + assertFalse(recordType.isAnnotationPresent(DbColumn.class)); + } + + @Test + void getAnnotationReturnsNullWhenNotPresent() throws NoSuchMethodException { + RecordType recordType = createRecordType(TestData.class); + assertNull(recordType.getAnnotation(DbColumn.class)); + } + + @Test + void getAnnotationsReturnsEmptyArrayWhenNotPresent() throws NoSuchMethodException { + RecordType recordType = createRecordType(TestData.class); + DbColumn[] annotations = recordType.getAnnotations(DbColumn.class); + assertNotNull(annotations); + assertEquals(0, annotations.length); + } + + @Test + void constructorRejectsNullType() throws NoSuchMethodException { + Constructor constructor = TestData.class.getDeclaredConstructors()[0]; + assertThrows(NullPointerException.class, () -> + new RecordType(null, constructor, List.of(), List.of())); + } + + @Test + void constructorRejectsNullConstructor() { + assertThrows(NullPointerException.class, () -> + new RecordType(TestData.class, null, List.of(), List.of())); + } + + @Test + void newInstanceWrapsConstructorException() throws NoSuchMethodException { + record ThrowingRecord(int value) implements Data { + ThrowingRecord { + if (value < 0) throw new IllegalArgumentException("negative"); + } + } + RecordType recordType = createRecordType(ThrowingRecord.class); + PersistenceException exception = assertThrows(PersistenceException.class, + () -> recordType.newInstance(new Object[]{-1})); + assertTrue(exception.getCause() instanceof IllegalArgumentException); + } + + @Test + void newInstanceWrapsPersistenceException() throws NoSuchMethodException { + record PersistenceThrowingRecord(int value) implements Data { + PersistenceThrowingRecord { + if (value < 0) throw new PersistenceException("persistence error"); + } + } + RecordType recordType = createRecordType(PersistenceThrowingRecord.class); + PersistenceException exception = assertThrows(PersistenceException.class, + () -> recordType.newInstance(new Object[]{-1})); + assertEquals("persistence error", exception.getMessage()); + } +} diff --git a/storm-foundation/src/test/java/st/orm/mapping/ResolverTest.java b/storm-foundation/src/test/java/st/orm/mapping/ResolverTest.java new file mode 100644 index 000000000..141e1c6b2 --- /dev/null +++ b/storm-foundation/src/test/java/st/orm/mapping/ResolverTest.java @@ -0,0 +1,88 @@ +package st.orm.mapping; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Method; +import java.util.List; +import org.junit.jupiter.api.Test; +import st.orm.Data; + +class ResolverTest { + + record TestData(int id, String userName) implements Data {} + + private RecordField createRecordField(String name, Class type) throws NoSuchMethodException { + Method method = TestData.class.getMethod(name); + return new RecordField(TestData.class, name, type, type, false, false, method, List.of()); + } + + private RecordType createRecordType(Class type) { + return new RecordType(type, type.getDeclaredConstructors()[0], List.of(), List.of()); + } + + @Test + void columnNameResolverDefaultCamelCaseToSnakeCase() throws NoSuchMethodException { + RecordField field = createRecordField("userName", String.class); + String result = ColumnNameResolver.DEFAULT.resolveColumnName(field); + assertEquals("user_name", result); + } + + @Test + void columnNameResolverCamelCaseToSnakeCase() throws NoSuchMethodException { + ColumnNameResolver resolver = ColumnNameResolver.camelCaseToSnakeCase(); + RecordField field = createRecordField("userName", String.class); + assertEquals("user_name", resolver.resolveColumnName(field)); + } + + @Test + void columnNameResolverToUpperCase() throws NoSuchMethodException { + ColumnNameResolver resolver = ColumnNameResolver.toUpperCase(ColumnNameResolver.DEFAULT); + RecordField field = createRecordField("userName", String.class); + assertEquals("USER_NAME", resolver.resolveColumnName(field)); + } + + @Test + void tableNameResolverDefaultCamelCaseToSnakeCase() { + RecordType recordType = createRecordType(TestData.class); + String result = TableNameResolver.DEFAULT.resolveTableName(recordType); + assertEquals("test_data", result); + } + + @Test + void tableNameResolverCamelCaseToSnakeCase() { + TableNameResolver resolver = TableNameResolver.camelCaseToSnakeCase(); + RecordType recordType = createRecordType(TestData.class); + assertEquals("test_data", resolver.resolveTableName(recordType)); + } + + @Test + void tableNameResolverToUpperCase() { + TableNameResolver resolver = TableNameResolver.toUpperCase(TableNameResolver.DEFAULT); + RecordType recordType = createRecordType(TestData.class); + assertEquals("TEST_DATA", resolver.resolveTableName(recordType)); + } + + @Test + void foreignKeyResolverDefaultCamelCaseToSnakeCase() throws NoSuchMethodException { + RecordField field = createRecordField("userName", String.class); + RecordType type = createRecordType(TestData.class); + String result = ForeignKeyResolver.DEFAULT.resolveColumnName(field, type); + assertEquals("user_name_id", result); + } + + @Test + void foreignKeyResolverCamelCaseToSnakeCase() throws NoSuchMethodException { + ForeignKeyResolver resolver = ForeignKeyResolver.camelCaseToSnakeCase(); + RecordField field = createRecordField("userName", String.class); + RecordType type = createRecordType(TestData.class); + assertEquals("user_name_id", resolver.resolveColumnName(field, type)); + } + + @Test + void foreignKeyResolverToUpperCase() throws NoSuchMethodException { + ForeignKeyResolver resolver = ForeignKeyResolver.toUpperCase(ForeignKeyResolver.DEFAULT); + RecordField field = createRecordField("userName", String.class); + RecordType type = createRecordType(TestData.class); + assertEquals("USER_NAME_ID", resolver.resolveColumnName(field, type)); + } +} diff --git a/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java b/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java index 6efd91480..39942319c 100644 --- a/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java +++ b/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java @@ -279,4 +279,35 @@ public void polymorphicJsonDeserializationShouldResolveCorrectSubtypeViaDiscrimi assertEquals(10, owner.size()); assertTrue(owner.stream().allMatch(x -> x.person instanceof PersonA)); } + + // Sealed interface with @JsonTypeName annotations on subtypes to cover that branch in getPermittedSubtypes. + + @JsonTypeInfo(use = NAME) + public sealed interface NamedPerson permits NamedPersonA, NamedPersonB {} + + @com.fasterxml.jackson.annotation.JsonTypeName("A") + public record NamedPersonA(String firstName, String lastName) implements NamedPerson {} + + @com.fasterxml.jackson.annotation.JsonTypeName("B") + public record NamedPersonB(String firstName, String lastName) implements NamedPerson {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithNamedPolymorphicPerson( + @PK Integer id, + @Nonnull @Json NamedPerson person, + @Nonnull @Json Address address, + @Nullable String telephone + ) implements Entity {} + + @Test + public void polymorphicJsonWithExplicitTypeNamesShouldResolveSubtype() { + // Uses @JsonTypeName("A") and @JsonTypeName("B") on sealed subtypes. + // This exercises the branch in getPermittedSubtypes where typeNameAnnotation is non-null. + var orm = of(dataSource); + var query = orm.query("SELECT id, JSON_OBJECT('@type' VALUE 'A', 'firstName' VALUE first_name, 'lastName' VALUE last_name) AS person, address, telephone FROM owner"); + var owner = query.getResultList(OwnerWithNamedPolymorphicPerson.class); + assertEquals(10, owner.size()); + assertTrue(owner.stream().allMatch(x -> x.person instanceof NamedPersonA)); + } } diff --git a/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterTest.java b/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterTest.java new file mode 100644 index 000000000..0328608de --- /dev/null +++ b/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterTest.java @@ -0,0 +1,247 @@ +package st.orm.jackson; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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 st.orm.core.template.ORMTemplate.of; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import javax.sql.DataSource; +import lombok.Builder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import st.orm.DbTable; +import st.orm.Entity; +import st.orm.Json; +import st.orm.PK; +import st.orm.PersistenceException; +import st.orm.jackson.model.Address; +import st.orm.jackson.model.Owner; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = IntegrationConfig.class) +@DataJpaTest(showSql = false) +public class JsonORMConverterTest { + + @Autowired + private DataSource dataSource; + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithFailOnUnknown( + @PK Integer id, + @Nonnull String firstName, + @Nonnull String lastName, + @Nonnull @Json(failOnUnknown = true) Address address, + @Nullable String telephone + ) implements Entity {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithFailOnMissing( + @PK Integer id, + @Nonnull String firstName, + @Nonnull String lastName, + @Nonnull @Json(failOnMissing = true) Address address, + @Nullable String telephone + ) implements Entity {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithBothFailOptions( + @PK Integer id, + @Nonnull String firstName, + @Nonnull String lastName, + @Nonnull @Json(failOnUnknown = true, failOnMissing = true) Address address, + @Nullable String telephone + ) implements Entity {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithNullableAddress( + @PK Integer id, + @Nonnull String firstName, + @Nonnull String lastName, + @Nullable @Json Address address, + @Nullable String telephone + ) implements Entity {} + + @Test + public void failOnUnknownTrueShouldStillWorkWithValidJson() { + // Standard address JSON has no unknown properties, so this should succeed. + var orm = of(dataSource); + var owner = orm.entity(OwnerWithFailOnUnknown.class).getById(1); + assertNotNull(owner.address()); + assertEquals("638 Cardinal Ave.", owner.address().address()); + } + + @Test + public void failOnMissingTrueShouldStillWorkWithCompleteJson() { + // Standard address JSON has all required properties, so this should succeed. + var orm = of(dataSource); + var owner = orm.entity(OwnerWithFailOnMissing.class).getById(1); + assertNotNull(owner.address()); + assertEquals("638 Cardinal Ave.", owner.address().address()); + } + + @Test + public void failOnUnknownAndFailOnMissingBothTrueShouldWork() { + // Both options enabled should work with valid complete JSON. + var orm = of(dataSource); + var owner = orm.entity(OwnerWithBothFailOptions.class).getById(1); + assertNotNull(owner.address()); + assertEquals("638 Cardinal Ave.", owner.address().address()); + } + + @Test + public void insertOwnerShouldSerializeJsonAddressToDatabase() { + // Tests the toDatabase path with a non-null JSON field. + var orm = of(dataSource); + var repository = orm.entity(Owner.class); + var address = new Address("271 University Ave", "Palo Alto"); + var owner = Owner.builder() + .firstName("Simon") + .lastName("McDonald") + .address(address) + .telephone("555-555-5555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertNotNull(inserted.address()); + assertEquals("271 University Ave", inserted.address().address()); + assertEquals("Palo Alto", inserted.address().city()); + } + + @Test + public void toDatabaseWithNullJsonFieldShouldProduceNull() { + // Tests the toDatabase path when the JSON field is null (nullable address). + var orm = of(dataSource); + var repository = orm.entity(OwnerWithNullableAddress.class); + var owner = OwnerWithNullableAddress.builder() + .firstName("Test") + .lastName("NullAddress") + .address(null) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertNull(inserted.address()); + } + + @Test + public void fromDatabaseWithNullJsonValueShouldReturnNullForNullableField() { + // Fetch an owner whose address was set to null. + var orm = of(dataSource); + var repository = orm.entity(OwnerWithNullableAddress.class); + var owner = OwnerWithNullableAddress.builder() + .firstName("Null") + .lastName("Address") + .address(null) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertNull(inserted.address()); + } + + @Test + public void fromDatabaseWithInvalidJsonShouldThrowException() { + // Query that produces invalid JSON for the address column. + var orm = of(dataSource); + var query = orm.query("SELECT id, first_name, last_name, 'not-valid-json' AS address, telephone FROM owner WHERE id = 1"); + assertThrows(PersistenceException.class, + () -> query.getSingleResult(OwnerWithFailOnUnknown.class)); + } + + @Test + public void failOnUnknownShouldRejectJsonWithExtraProperties() { + // Use raw query to inject JSON with an extra property. + var orm = of(dataSource); + var query = orm.query(""" + SELECT id, first_name, last_name, + '{"address":"test","city":"test","extraField":"unexpected"}' AS address, + telephone + FROM owner WHERE id = 1"""); + assertThrows(PersistenceException.class, + () -> query.getSingleResult(OwnerWithFailOnUnknown.class)); + } + + @Test + public void selectAllOwnersWithFailOnUnknownShouldReturnAll() { + // All 10 standard owners have valid address JSON with no unknown properties. + var orm = of(dataSource); + var owners = orm.entity(OwnerWithFailOnUnknown.class).select().getResultList(); + assertEquals(10, owners.size()); + } + + @Test + public void selectAllOwnersWithFailOnMissingShouldReturnAll() { + // All 10 standard owners have complete address JSON. + var orm = of(dataSource); + var owners = orm.entity(OwnerWithFailOnMissing.class).select().getResultList(); + assertEquals(10, owners.size()); + } + + @Test + public void updateOwnerJsonFieldShouldPersistChanges() { + // Tests the toDatabase path during an update. + var orm = of(dataSource); + var repository = orm.entity(Owner.class); + var owner = repository.getById(1); + var newAddress = new Address("100 Main St", "Springfield"); + repository.update(owner.toBuilder().address(newAddress).build()); + var updated = repository.getById(1); + assertEquals("100 Main St", updated.address().address()); + assertEquals("Springfield", updated.address().city()); + } + + @Test + public void updateOwnerNullableAddressToNullAndBackShouldRoundTrip() { + // Insert with address, update to null, read back (null), update back to address, read back. + var orm = of(dataSource); + var repository = orm.entity(OwnerWithNullableAddress.class); + var address = new Address("test", "city"); + var owner = OwnerWithNullableAddress.builder() + .firstName("Round") + .lastName("Trip") + .address(address) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertNotNull(inserted.address()); + + // Update to null address. + repository.update(inserted.toBuilder().address(null).build()); + var withNullAddress = repository.getById(inserted.id()); + assertNull(withNullAddress.address()); + + // Update back to non-null address. + repository.update(withNullAddress.toBuilder().address(new Address("new", "addr")).build()); + var restored = repository.getById(inserted.id()); + assertNotNull(restored.address()); + assertEquals("new", restored.address().address()); + } + + @Test + public void selectNullableAddressWithNullValueShouldReturnNull() { + // Directly query for a row with NULL address via raw SQL to exercise fromDatabase null path. + var orm = of(dataSource); + var repository = orm.entity(OwnerWithNullableAddress.class); + var owner = OwnerWithNullableAddress.builder() + .firstName("Direct") + .lastName("Null") + .address(null) + .telephone("555") + .build(); + repository.insert(owner); + + // Use select to get all including the new null-address owner. + var allOwners = repository.select().getResultList(); + long nullAddressCount = allOwners.stream().filter(o -> o.address() == null).count(); + assertTrue(nullAddressCount >= 1); + } +} diff --git a/storm-jackson2/src/test/java/st/orm/jackson/StormModuleTest.java b/storm-jackson2/src/test/java/st/orm/jackson/StormModuleTest.java new file mode 100644 index 000000000..87473cf2d --- /dev/null +++ b/storm-jackson2/src/test/java/st/orm/jackson/StormModuleTest.java @@ -0,0 +1,767 @@ +package st.orm.jackson; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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 com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import st.orm.Data; +import st.orm.Entity; +import st.orm.PK; +import st.orm.Projection; +import st.orm.Ref; +import st.orm.core.spi.RefFactory; + +public class StormModuleTest { + + // Test entities and projections. + + public record SimpleEntity(@PK Integer id, @Nonnull String name) implements Entity {} + + public record StringIdEntity(@PK String id, @Nonnull String name) implements Entity {} + + public record LongIdEntity(@PK Long id, @Nonnull String name) implements Entity {} + + public record SimpleProjection(@PK Integer id, @Nonnull String label) implements Projection {} + + // A Data record without @PK annotation - triggers null pkType fallback paths. + public record NoPkData(String value) implements Data {} + + // Wrapper records with Ref fields. + + public record EntityRefHolder(@Nonnull Ref entity) {} + + public record StringIdRefHolder(@Nonnull Ref entity) {} + + public record LongIdRefHolder(@Nonnull Ref entity) {} + + public record ProjectionRefHolder(@Nullable Ref projection) {} + + public record NullableRefHolder(@Nullable Ref entity) {} + + public record RefListHolder(@Nonnull List> entities) {} + + public record RefSetHolder(@Nonnull Set> entities) {} + + public record RefMapHolder(@Nonnull Map> entities) {} + + public record NoPkRefHolder(@Nullable Ref data) {} + + public record NoPkRefListHolder(@Nonnull List> data) {} + + // Tests for constructor variations. + + @Test + public void constructorWithNullRefFactoryShouldCreateModuleWithDetachedRefs() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule((RefFactory) null)); + + String json = "{\"entity\":1}"; + EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); + assertNotNull(holder.entity()); + assertEquals(1, holder.entity().id()); + } + + @Test + public void constructorWithRefFactoryShouldUseFactoryForDeserialization() throws Exception { + RefFactory factory = new RefFactory() { + @Override + public Ref create(@Nonnull Class type, @Nonnull ID pk) { + return Ref.of(type, pk); + } + + @Override + public Ref create(@Nonnull T record, @Nonnull ID pk) { + return Ref.of(type(record), pk); + } + + @SuppressWarnings("unchecked") + private Class type(T record) { + return (Class) record.getClass(); + } + }; + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule(factory)); + + String json = "{\"entity\":42}"; + EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); + assertNotNull(holder.entity()); + assertEquals(42, holder.entity().id()); + } + + @Test + public void constructorWithSupplierShouldUseSuppliedFactory() throws Exception { + RefFactory factory = new RefFactory() { + @Override + public Ref create(@Nonnull Class type, @Nonnull ID pk) { + return Ref.of(type, pk); + } + + @Override + public Ref create(@Nonnull T record, @Nonnull ID pk) { + return Ref.of(type(record), pk); + } + + @SuppressWarnings("unchecked") + private Class type(T record) { + return (Class) record.getClass(); + } + }; + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule(() -> factory)); + + String json = "{\"entity\":7}"; + EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); + assertNotNull(holder.entity()); + assertEquals(7, holder.entity().id()); + } + + @Test + public void constructorWithNullSupplierResultShouldCreateDetachedRef() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule(() -> null)); + + String json = "{\"entity\":7}"; + EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); + assertNotNull(holder.entity()); + assertEquals(7, holder.entity().id()); + } + + // Tests for Ref serialization. + + @Test + public void unloadedRefShouldSerializeAsRawId() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + Ref ref = Ref.of(SimpleEntity.class, 42); + EntityRefHolder holder = new EntityRefHolder(ref); + String json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":42}", json); + } + + @Test + public void loadedEntityRefShouldSerializeWithEntityWrapper() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + SimpleEntity entity = new SimpleEntity(1, "Alice"); + Ref ref = Ref.of(entity); + EntityRefHolder holder = new EntityRefHolder(ref); + String json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":{\"@entity\":{\"id\":1,\"name\":\"Alice\"}}}", json); + } + + @Test + public void loadedProjectionRefShouldSerializeWithProjectionWrapperAndId() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + SimpleProjection projection = new SimpleProjection(5, "Test"); + Ref ref = Ref.of(projection, 5); + ProjectionRefHolder holder = new ProjectionRefHolder(ref); + String json = mapper.writeValueAsString(holder); + assertEquals("{\"projection\":{\"@id\":5,\"@projection\":{\"id\":5,\"label\":\"Test\"}}}", json); + } + + @Test + public void unloadedRefWithStringIdShouldSerializeAsStringValue() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + Ref ref = Ref.of(StringIdEntity.class, "abc-123"); + StringIdRefHolder holder = new StringIdRefHolder(ref); + String json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":\"abc-123\"}", json); + } + + @Test + public void unloadedRefWithLongIdShouldSerializeAsLongValue() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + Ref ref = Ref.of(LongIdEntity.class, 9999999999L); + LongIdRefHolder holder = new LongIdRefHolder(ref); + String json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":9999999999}", json); + } + + // Tests for Ref deserialization. + + @Test + public void rawIntegerIdShouldDeserializeToUnloadedRef() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"entity\":42}"; + EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); + assertNotNull(holder.entity()); + assertEquals(42, holder.entity().id()); + assertNull(holder.entity().getOrNull()); + } + + @Test + public void rawStringIdShouldDeserializeToUnloadedRef() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"entity\":\"abc-123\"}"; + StringIdRefHolder holder = mapper.readValue(json, StringIdRefHolder.class); + assertNotNull(holder.entity()); + assertEquals("abc-123", holder.entity().id()); + } + + @Test + public void rawLongIdShouldDeserializeToUnloadedRef() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"entity\":9999999999}"; + LongIdRefHolder holder = mapper.readValue(json, LongIdRefHolder.class); + assertNotNull(holder.entity()); + assertEquals(9999999999L, holder.entity().id()); + } + + @Test + public void entityObjectShouldDeserializeToLoadedRef() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"entity\":{\"@entity\":{\"id\":1,\"name\":\"Alice\"}}}"; + EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); + assertNotNull(holder.entity()); + assertEquals(1, holder.entity().id()); + SimpleEntity loadedEntity = holder.entity().getOrNull(); + assertNotNull(loadedEntity); + assertEquals("Alice", loadedEntity.name()); + } + + @Test + public void projectionObjectShouldDeserializeToLoadedRef() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"projection\":{\"@id\":5,\"@projection\":{\"id\":5,\"label\":\"Test\"}}}"; + ProjectionRefHolder holder = mapper.readValue(json, ProjectionRefHolder.class); + assertNotNull(holder.projection()); + assertEquals(5, holder.projection().id()); + SimpleProjection loadedProjection = holder.projection().getOrNull(); + assertNotNull(loadedProjection); + assertEquals("Test", loadedProjection.label()); + } + + @Test + public void nullRefShouldDeserializeToNull() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"entity\":null}"; + NullableRefHolder holder = mapper.readValue(json, NullableRefHolder.class); + assertNull(holder.entity()); + } + + @Test + public void refListWithNullElementsShouldDeserializeCorrectly() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"entities\":[null,1,2]}"; + RefListHolder holder = mapper.readValue(json, RefListHolder.class); + assertEquals(3, holder.entities().size()); + assertNull(holder.entities().get(0)); + assertEquals(1, holder.entities().get(1).id()); + assertEquals(2, holder.entities().get(2).id()); + } + + @Test + public void refObjectWithoutEntityOrProjectionFieldShouldThrowException() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"entity\":{\"unknown\":\"field\"}}"; + assertThrows(JsonMappingException.class, () -> + mapper.readValue(json, EntityRefHolder.class)); + } + + @Test + public void projectionObjectWithoutIdFieldShouldThrowException() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"projection\":{\"@projection\":{\"id\":5,\"label\":\"Test\"}}}"; + assertThrows(JsonMappingException.class, () -> + mapper.readValue(json, ProjectionRefHolder.class)); + } + + @Test + public void entityRefSerializationShouldRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Unloaded ref round-trip. + Ref unloadedRef = Ref.of(SimpleEntity.class, 42); + EntityRefHolder unloadedHolder = new EntityRefHolder(unloadedRef); + String unloadedJson = mapper.writeValueAsString(unloadedHolder); + EntityRefHolder deserializedUnloaded = mapper.readValue(unloadedJson, EntityRefHolder.class); + assertEquals(unloadedHolder, deserializedUnloaded); + + // Loaded ref round-trip. + SimpleEntity entity = new SimpleEntity(1, "Alice"); + Ref loadedRef = Ref.of(entity); + EntityRefHolder loadedHolder = new EntityRefHolder(loadedRef); + String loadedJson = mapper.writeValueAsString(loadedHolder); + EntityRefHolder deserializedLoaded = mapper.readValue(loadedJson, EntityRefHolder.class); + assertEquals(loadedHolder, deserializedLoaded); + } + + @Test + public void projectionRefSerializationShouldRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Unloaded projection ref round-trip. + Ref unloadedRef = Ref.of(SimpleProjection.class, 5); + ProjectionRefHolder unloadedHolder = new ProjectionRefHolder(unloadedRef); + String unloadedJson = mapper.writeValueAsString(unloadedHolder); + ProjectionRefHolder deserializedUnloaded = mapper.readValue(unloadedJson, ProjectionRefHolder.class); + assertEquals(unloadedHolder, deserializedUnloaded); + + // Loaded projection ref round-trip. + SimpleProjection projection = new SimpleProjection(5, "Test"); + Ref loadedRef = Ref.of(projection, 5); + ProjectionRefHolder loadedHolder = new ProjectionRefHolder(loadedRef); + String loadedJson = mapper.writeValueAsString(loadedHolder); + ProjectionRefHolder deserializedLoaded = mapper.readValue(loadedJson, ProjectionRefHolder.class); + assertEquals(loadedHolder, deserializedLoaded); + } + + @Test + public void stringIdRefShouldRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + Ref ref = Ref.of(StringIdEntity.class, "my-id"); + StringIdRefHolder holder = new StringIdRefHolder(ref); + String json = mapper.writeValueAsString(holder); + StringIdRefHolder deserialized = mapper.readValue(json, StringIdRefHolder.class); + assertEquals(holder, deserialized); + } + + @Test + public void loadedStringIdEntityRefShouldRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + StringIdEntity entity = new StringIdEntity("abc", "Test"); + Ref ref = Ref.of(entity); + StringIdRefHolder holder = new StringIdRefHolder(ref); + String json = mapper.writeValueAsString(holder); + StringIdRefHolder deserialized = mapper.readValue(json, StringIdRefHolder.class); + assertEquals(holder, deserialized); + } + + @Test + public void loadedLongIdEntityRefShouldRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + LongIdEntity entity = new LongIdEntity(999L, "Test"); + Ref ref = Ref.of(entity); + LongIdRefHolder holder = new LongIdRefHolder(ref); + String json = mapper.writeValueAsString(holder); + LongIdRefHolder deserialized = mapper.readValue(json, LongIdRefHolder.class); + assertEquals(holder, deserialized); + } + + // Tests for fallback deserialization paths (when pkType is null). + + @Test + public void noPkDataWithSmallIntegerIdShouldDeserializeViaFallback() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // NoPkData has no @PK field, so pkType will be null, triggering the fallback integer path. + String json = "{\"data\":42}"; + NoPkRefHolder holder = mapper.readValue(json, NoPkRefHolder.class); + assertNotNull(holder.data()); + assertEquals(42, ((Number) holder.data().id()).intValue()); + } + + @Test + public void noPkDataWithLargeIntegerIdShouldDeserializeAsLongViaFallback() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Large number exceeding int range triggers the long branch in fallback. + String json = "{\"data\":9999999999}"; + NoPkRefHolder holder = mapper.readValue(json, NoPkRefHolder.class); + assertNotNull(holder.data()); + assertEquals(9999999999L, holder.data().id()); + } + + @Test + public void noPkDataWithFloatIdShouldDeserializeAsDoubleViaFallback() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Float value triggers VALUE_NUMBER_FLOAT branch in fallback. + String json = "{\"data\":3.14}"; + NoPkRefHolder holder = mapper.readValue(json, NoPkRefHolder.class); + assertNotNull(holder.data()); + assertEquals(3.14, holder.data().id()); + } + + @Test + public void noPkDataWithStringIdShouldDeserializeAsStringViaFallback() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // String value triggers VALUE_STRING branch in fallback. + String json = "{\"data\":\"my-id\"}"; + NoPkRefHolder holder = mapper.readValue(json, NoPkRefHolder.class); + assertNotNull(holder.data()); + assertEquals("my-id", holder.data().id()); + } + + // Tests for deserializeIdFromNode fallback paths (projection deserialization). + + public record NoPkProjection(String label) implements Projection {} + + public record NoPkProjectionRefHolder(@Nullable Ref projection) {} + + @Test + public void noPkProjectionWithIntIdNodeShouldDeserializeViaNodeFallback() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Projection path with @id node and no @PK on target - exercises deserializeIdFromNode int fallback. + String json = "{\"projection\":{\"@id\":42,\"@projection\":{\"label\":\"Test\"}}}"; + NoPkProjectionRefHolder holder = mapper.readValue(json, NoPkProjectionRefHolder.class); + assertNotNull(holder.projection()); + assertEquals(42, holder.projection().id()); + } + + @Test + public void noPkProjectionWithLongIdNodeShouldDeserializeViaNodeFallback() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Long value in node triggers isLong fallback. + String json = "{\"projection\":{\"@id\":9999999999,\"@projection\":{\"label\":\"Test\"}}}"; + NoPkProjectionRefHolder holder = mapper.readValue(json, NoPkProjectionRefHolder.class); + assertNotNull(holder.projection()); + assertEquals(9999999999L, holder.projection().id()); + } + + @Test + public void noPkProjectionWithDoubleIdNodeShouldDeserializeViaNodeFallback() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Double value in node triggers isDouble fallback. + String json = "{\"projection\":{\"@id\":3.14,\"@projection\":{\"label\":\"Test\"}}}"; + NoPkProjectionRefHolder holder = mapper.readValue(json, NoPkProjectionRefHolder.class); + assertNotNull(holder.projection()); + assertEquals(3.14, holder.projection().id()); + } + + @Test + public void noPkProjectionWithStringIdNodeShouldDeserializeViaNodeFallback() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // String value in node triggers isTextual fallback. + String json = "{\"projection\":{\"@id\":\"abc\",\"@projection\":{\"label\":\"Test\"}}}"; + NoPkProjectionRefHolder holder = mapper.readValue(json, NoPkProjectionRefHolder.class); + assertNotNull(holder.projection()); + assertEquals("abc", holder.projection().id()); + } + + @Test + public void noPkProjectionWithObjectIdNodeShouldDeserializeViaObjectFallback() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Object value in node triggers the final Object fallback. + String json = "{\"projection\":{\"@id\":{\"key\":\"val\"},\"@projection\":{\"label\":\"Test\"}}}"; + NoPkProjectionRefHolder holder = mapper.readValue(json, NoPkProjectionRefHolder.class); + assertNotNull(holder.projection()); + assertNotNull(holder.projection().id()); + } + + // Tests for Ref in collections (Set, Map). + + @Test + public void refInSetShouldDeserializeCorrectly() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"entities\":[1,2,3]}"; + RefSetHolder holder = mapper.readValue(json, RefSetHolder.class); + assertEquals(3, holder.entities().size()); + } + + @Test + public void refInMapValueShouldDeserializeCorrectly() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"entities\":{\"a\":1,\"b\":2}}"; + RefMapHolder holder = mapper.readValue(json, RefMapHolder.class); + assertEquals(2, holder.entities().size()); + assertEquals(1, holder.entities().get("a").id()); + assertEquals(2, holder.entities().get("b").id()); + } + + // Tests for error paths. + + @Test + public void booleanTokenShouldThrowExceptionDuringRefDeserialization() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Boolean token is not a valid Ref value, should trigger the default case in deserialize. + String json = "{\"entity\":true}"; + assertThrows(JsonMappingException.class, () -> + mapper.readValue(json, EntityRefHolder.class)); + } + + @Test + public void arrayTokenShouldThrowExceptionDuringRefDeserialization() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Array token is not a valid Ref value. + String json = "{\"entity\":[1,2,3]}"; + assertThrows(JsonMappingException.class, () -> + mapper.readValue(json, EntityRefHolder.class)); + } + + // Tests for list deserialization with NoPk data (fallback path in list context). + + @Test + public void noPkRefListWithMixedIdTypesShouldDeserializeViaFallback() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"data\":[1,\"two\",3]}"; + NoPkRefListHolder holder = mapper.readValue(json, NoPkRefListHolder.class); + assertEquals(3, holder.data().size()); + assertEquals(1, ((Number) holder.data().get(0).id()).intValue()); + assertEquals("two", holder.data().get(1).id()); + assertEquals(3, ((Number) holder.data().get(2).id()).intValue()); + } + + // Test for serialization of loaded Data ref (not Entity, not Projection) + // when Data type is used as loaded ref via @entity path. + + @Test + public void unloadedNoPkRefShouldSerializeAsRawId() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + Ref ref = Ref.of(NoPkData.class, 42); + NoPkRefHolder holder = new NoPkRefHolder(ref); + String json = mapper.writeValueAsString(holder); + // With no pkType, falls through to defaultSerializeValue. + assertTrue(json.contains("42")); + } + + // Tests for the resolveRefTargetType edge cases. + + @Test + public void refSetSerializationShouldRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + Set> refs = Set.of( + Ref.of(SimpleEntity.class, 1), + Ref.of(SimpleEntity.class, 2)); + RefSetHolder holder = new RefSetHolder(refs); + String json = mapper.writeValueAsString(holder); + assertNotNull(json); + // Verify it deserializes back. + RefSetHolder deserialized = mapper.readValue(json, RefSetHolder.class); + assertEquals(2, deserialized.entities().size()); + } + + @Test + public void refMapSerializationShouldWork() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + Map> refs = Map.of( + "a", Ref.of(SimpleEntity.class, 1), + "b", Ref.of(SimpleEntity.class, 2)); + RefMapHolder holder = new RefMapHolder(refs); + String json = mapper.writeValueAsString(holder); + assertNotNull(json); + } + + // Tests for createLoadedRefWithId - Entity via @projection path. + + @Test + public void projectionPathWithEntityDataShouldCreateEntityRef() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // When @projection contains an Entity, createLoadedRefWithId should take the Entity branch. + // Use EntityRefHolder but with @projection format instead of @entity format. + String json = "{\"entity\":{\"@id\":1,\"@projection\":{\"id\":1,\"name\":\"Alice\"}}}"; + EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); + assertNotNull(holder.entity()); + assertEquals(1, holder.entity().id()); + // Since SimpleEntity is an Entity, it should take the Entity branch in createLoadedRefWithId. + assertNotNull(holder.entity().getOrNull()); + } + + // Tests for createLoadedRefWithId - Data that is not Entity or Projection, with id. + + public record DataRefHolder(@Nullable Ref data) {} + + @Test + public void projectionPathWithPlainDataAndIdShouldCreateUnloadedRef() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // NoPkData is Data but not Entity or Projection. + // @projection with @id should take the fallback "if (id != null) return Ref.of(targetClass, id)" path. + String json = "{\"data\":{\"@id\":42,\"@projection\":{\"value\":\"test\"}}}"; + DataRefHolder holder = mapper.readValue(json, DataRefHolder.class); + assertNotNull(holder.data()); + assertEquals(42, ((Number) holder.data().id()).intValue()); + } + + // Test for createLoadedRef with non-Entity in @entity path. + + @Test + public void entityPathWithNonEntityDataShouldThrowException() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // NoPkData is Data but not Entity. Using @entity with non-Entity data should throw. + String json = "{\"data\":{\"@entity\":{\"value\":\"test\"}}}"; + assertThrows(JsonMappingException.class, () -> + mapper.readValue(json, DataRefHolder.class)); + } + + // Test for resolvePkType returning NO_PK. + + @Test + public void noPkDataSerializerShouldHandleMissingPkTypeGracefully() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // NoPkData has no @PK, so serializeId falls through to defaultSerializeValue. + Ref ref = Ref.of(NoPkData.class, "text-id"); + DataRefHolder holder = new DataRefHolder(ref); + String json = mapper.writeValueAsString(holder); + assertTrue(json.contains("text-id")); + } + + // Test for null nullable projection ref in holder. + + @Test + public void nullProjectionRefShouldSerializeToNull() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + ProjectionRefHolder holder = new ProjectionRefHolder(null); + String json = mapper.writeValueAsString(holder); + assertEquals("{\"projection\":null}", json); + } + + @Test + public void nullProjectionRefShouldDeserializeFromNull() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + String json = "{\"projection\":null}"; + ProjectionRefHolder holder = mapper.readValue(json, ProjectionRefHolder.class); + assertNull(holder.projection()); + } + + // Test for deserializeId default token case. + + @Test + public void noPkDataWithBooleanShouldThrowExceptionViaFallbackPath() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Boolean values trigger the default case in the main deserialize switch (not NUMBER_INT/FLOAT/STRING). + String json = "{\"data\":true}"; + assertThrows(JsonMappingException.class, () -> + mapper.readValue(json, NoPkRefHolder.class)); + } + + // Test for createRefFromId with null id. + + @Test + public void noPkRefWithNullIdInListShouldReturnNullRef() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Test null handling within list deserialization for NoPkData refs. + String json = "{\"data\":[null, 1]}"; + NoPkRefListHolder holder = mapper.readValue(json, NoPkRefListHolder.class); + assertEquals(2, holder.data().size()); + assertNull(holder.data().get(0)); + assertNotNull(holder.data().get(1)); + } + + // Test for Ref field with Double PK type. + + public record DoubleIdEntity(@PK Double id, @Nonnull String name) implements Entity {} + + public record DoubleIdRefHolder(@Nonnull Ref entity) {} + + @Test + public void doubleIdRefShouldRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + Ref ref = Ref.of(DoubleIdEntity.class, 3.14); + DoubleIdRefHolder holder = new DoubleIdRefHolder(ref); + String json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":3.14}", json); + DoubleIdRefHolder deserialized = mapper.readValue(json, DoubleIdRefHolder.class); + assertEquals(3.14, deserialized.entity().id()); + } + + @Test + public void loadedDoubleIdEntityRefShouldRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + DoubleIdEntity entity = new DoubleIdEntity(2.71, "Euler"); + Ref ref = Ref.of(entity); + DoubleIdRefHolder holder = new DoubleIdRefHolder(ref); + String json = mapper.writeValueAsString(holder); + assertTrue(json.contains("@entity")); + DoubleIdRefHolder deserialized = mapper.readValue(json, DoubleIdRefHolder.class); + assertEquals(2.71, deserialized.entity().id()); + } + + // Test for deserializing raw Ref without type information should throw. + + @SuppressWarnings("rawtypes") + @Test + public void rawRefWithoutTypeInfoShouldThrowOnDeserialization() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new StormModule()); + + // Deserializing a raw Ref (no generic type info) should throw because targetType is null. + String json = "42"; + assertThrows(JsonMappingException.class, () -> + mapper.readValue(json, Ref.class)); + } +} diff --git a/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java b/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java index 6efd91480..3b5f70998 100644 --- a/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java +++ b/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java @@ -3,6 +3,8 @@ import static com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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 st.orm.core.template.ORMTemplate.of; @@ -268,6 +270,148 @@ public record OwnerWithPolymorphicPerson( ) implements Entity { } + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithNullableAddress( + @PK Integer id, + @Nonnull String firstName, + @Nonnull String lastName, + @Nullable @Json Address address, + @Nullable String telephone + ) implements Entity { + } + + @Test + public void insertEntityWithNullJsonFieldShouldPersistNullAndReadBackAsNull() { + // When the @Json field is nullable and the value is null, toDatabase should serialize as null + // and fromDatabase should return null when reading from the database. + var orm = of(dataSource); + var repository = orm.entity(OwnerWithNullableAddress.class); + var owner = OwnerWithNullableAddress.builder() + .firstName("NullAddr") + .lastName("Test") + .address(null) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertNull(inserted.address()); + } + + @Test + public void selectEntityWithNullJsonFieldShouldReturnNull() { + // Insert a row with null address directly, then select it. + var orm = of(dataSource); + orm.query("INSERT INTO owner (first_name, last_name, address, telephone) VALUES ('Null', 'Test', NULL, '555')") + .executeUpdate(); + var result = orm.query("SELECT id, first_name, last_name, address, telephone FROM owner WHERE first_name = 'Null'") + .getSingleResult(OwnerWithNullableAddress.class); + assertNull(result.address()); + } + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithFailOnUnknown( + @PK Integer id, + @Nonnull @Json(failOnUnknown = true) Address address, + @Nullable String telephone + ) implements Entity { + } + + @Test + public void jsonWithFailOnUnknownTrueShouldCreateMapperWithStrictMode() { + // Exercises the @Json(failOnUnknown = true) branch in JsonORMConverterImpl constructor, + // which skips calling builder.disable(FAIL_ON_UNKNOWN_PROPERTIES). + // Owner id=1 has a valid address, so deserialization should succeed. + var orm = of(dataSource); + var result = orm.query("SELECT id, address, telephone FROM owner WHERE id = 1") + .getSingleResult(OwnerWithFailOnUnknown.class); + assertNotNull(result.address()); + } + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithFailOnMissing( + @PK Integer id, + @Nonnull @Json(failOnMissing = true) Address address, + @Nullable String telephone + ) implements Entity { + } + + @Test + public void jsonWithFailOnMissingTrueShouldCreateMapperWithStrictMode() { + // Exercises the @Json(failOnMissing = true) branch in JsonORMConverterImpl constructor, + // which skips calling builder.disable(FAIL_ON_MISSING_CREATOR_PROPERTIES). + // Owner id=1 has a valid address with all fields, so deserialization should succeed. + var orm = of(dataSource); + var result = orm.query("SELECT id, address, telephone FROM owner WHERE id = 1") + .getSingleResult(OwnerWithFailOnMissing.class); + assertNotNull(result.address()); + } + + // Custom serializer for Address. + public static class AddressSerializer extends tools.jackson.databind.ser.std.StdSerializer
{ + public AddressSerializer() { + super(Address.class); + } + + @Override + public void serialize(Address value, tools.jackson.core.JsonGenerator gen, + tools.jackson.databind.SerializationContext ctxt) + throws tools.jackson.core.JacksonException { + gen.writeString(value.address() + " | " + value.city()); + } + } + + // Custom deserializer for Address. + public static class AddressDeserializer extends tools.jackson.databind.deser.std.StdDeserializer
{ + public AddressDeserializer() { + super(Address.class); + } + + @Override + public Address deserialize(tools.jackson.core.JsonParser parser, + tools.jackson.databind.DeserializationContext ctxt) + throws tools.jackson.core.JacksonException { + String text = parser.getText(); + String[] parts = text.split(" \\| "); + return new Address(parts[0], parts[1]); + } + } + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithCustomSerializers( + @PK Integer id, + @Nonnull String firstName, + @Nonnull String lastName, + @Nonnull @Json + @tools.jackson.databind.annotation.JsonSerialize(using = AddressSerializer.class) + @tools.jackson.databind.annotation.JsonDeserialize(using = AddressDeserializer.class) + Address address, + @Nullable String telephone + ) implements Entity { + } + + @Test + public void customJsonSerializerAndDeserializerShouldBeUsedForJsonField() { + // Exercises the custom @JsonSerialize/@JsonDeserialize annotation branches + // in JsonORMConverterImpl constructor (lines 106-126). + var orm = of(dataSource); + var repository = orm.entity(OwnerWithCustomSerializers.class); + var address = new Address("123 Main St", "Springfield"); + var owner = OwnerWithCustomSerializers.builder() + .firstName("Test") + .lastName("User") + .address(address) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + // The custom serializer stores "address | city" as a plain string. + // The custom deserializer should parse it back. + assertEquals("123 Main St", inserted.address().address()); + assertEquals("Springfield", inserted.address().city()); + } + @Test public void polymorphicJsonDeserializationShouldResolveCorrectSubtypeViaDiscriminator() { // Uses a sealed interface with @JsonTypeInfo(use = NAME) and two permitted subtypes. @@ -279,4 +423,35 @@ public void polymorphicJsonDeserializationShouldResolveCorrectSubtypeViaDiscrimi assertEquals(10, owner.size()); assertTrue(owner.stream().allMatch(x -> x.person instanceof PersonA)); } + + // Sealed interface with @JsonTypeName annotations on subtypes to cover that branch in getPermittedSubtypes. + + @JsonTypeInfo(use = NAME) + public sealed interface NamedPerson permits NamedPersonA, NamedPersonB {} + + @com.fasterxml.jackson.annotation.JsonTypeName("A") + public record NamedPersonA(String firstName, String lastName) implements NamedPerson {} + + @com.fasterxml.jackson.annotation.JsonTypeName("B") + public record NamedPersonB(String firstName, String lastName) implements NamedPerson {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithNamedPolymorphicPerson( + @PK Integer id, + @Nonnull @Json NamedPerson person, + @Nonnull @Json Address address, + @Nullable String telephone + ) implements Entity {} + + @Test + public void polymorphicJsonWithExplicitTypeNamesShouldResolveSubtype() { + // Uses @JsonTypeName("A") and @JsonTypeName("B") on sealed subtypes. + // This exercises the branch in getPermittedSubtypes where typeNameAnnotation is non-null. + var orm = of(dataSource); + var query = orm.query("SELECT id, JSON_OBJECT('@type' VALUE 'A', 'firstName' VALUE first_name, 'lastName' VALUE last_name) AS person, address, telephone FROM owner"); + var owner = query.getResultList(OwnerWithNamedPolymorphicPerson.class); + assertEquals(10, owner.size()); + assertTrue(owner.stream().allMatch(x -> x.person instanceof NamedPersonA)); + } } diff --git a/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterTest.java b/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterTest.java new file mode 100644 index 000000000..3d61d4dab --- /dev/null +++ b/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterTest.java @@ -0,0 +1,221 @@ +package st.orm.jackson; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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 st.orm.core.template.ORMTemplate.of; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import javax.sql.DataSource; +import lombok.Builder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import st.orm.DbTable; +import st.orm.Entity; +import st.orm.Json; +import st.orm.PK; +import st.orm.PersistenceException; +import st.orm.jackson.model.Address; +import st.orm.jackson.model.Owner; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = IntegrationConfig.class) +@DataJpaTest(showSql = false) +public class JsonORMConverterTest { + + @Autowired + private DataSource dataSource; + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithFailOnUnknown( + @PK Integer id, + @Nonnull String firstName, + @Nonnull String lastName, + @Nonnull @Json(failOnUnknown = true) Address address, + @Nullable String telephone + ) implements Entity {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithFailOnMissing( + @PK Integer id, + @Nonnull String firstName, + @Nonnull String lastName, + @Nonnull @Json(failOnMissing = true) Address address, + @Nullable String telephone + ) implements Entity {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithBothFailOptions( + @PK Integer id, + @Nonnull String firstName, + @Nonnull String lastName, + @Nonnull @Json(failOnUnknown = true, failOnMissing = true) Address address, + @Nullable String telephone + ) implements Entity {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithNullableAddress( + @PK Integer id, + @Nonnull String firstName, + @Nonnull String lastName, + @Nullable @Json Address address, + @Nullable String telephone + ) implements Entity {} + + @Test + public void failOnUnknownTrueShouldStillWorkWithValidJson() { + var orm = of(dataSource); + var owner = orm.entity(OwnerWithFailOnUnknown.class).getById(1); + assertNotNull(owner.address()); + assertEquals("638 Cardinal Ave.", owner.address().address()); + } + + @Test + public void failOnMissingTrueShouldStillWorkWithCompleteJson() { + var orm = of(dataSource); + var owner = orm.entity(OwnerWithFailOnMissing.class).getById(1); + assertNotNull(owner.address()); + assertEquals("638 Cardinal Ave.", owner.address().address()); + } + + @Test + public void failOnUnknownAndFailOnMissingBothTrueShouldWork() { + var orm = of(dataSource); + var owner = orm.entity(OwnerWithBothFailOptions.class).getById(1); + assertNotNull(owner.address()); + assertEquals("638 Cardinal Ave.", owner.address().address()); + } + + @Test + public void insertOwnerShouldSerializeJsonAddressToDatabase() { + var orm = of(dataSource); + var repository = orm.entity(Owner.class); + var address = new Address("271 University Ave", "Palo Alto"); + var owner = Owner.builder() + .firstName("Simon") + .lastName("McDonald") + .address(address) + .telephone("555-555-5555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertNotNull(inserted.address()); + assertEquals("271 University Ave", inserted.address().address()); + assertEquals("Palo Alto", inserted.address().city()); + } + + @Test + public void toDatabaseWithNullJsonFieldShouldProduceNull() { + var orm = of(dataSource); + var repository = orm.entity(OwnerWithNullableAddress.class); + var owner = OwnerWithNullableAddress.builder() + .firstName("Test") + .lastName("NullAddress") + .address(null) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertNull(inserted.address()); + } + + @Test + public void fromDatabaseWithNullJsonValueShouldReturnNullForNullableField() { + var orm = of(dataSource); + var repository = orm.entity(OwnerWithNullableAddress.class); + var owner = OwnerWithNullableAddress.builder() + .firstName("Null") + .lastName("Address") + .address(null) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertNull(inserted.address()); + } + + @Test + public void fromDatabaseWithInvalidJsonShouldThrowException() { + var orm = of(dataSource); + var query = orm.query("SELECT id, first_name, last_name, 'not-valid-json' AS address, telephone FROM owner WHERE id = 1"); + assertThrows(PersistenceException.class, + () -> query.getSingleResult(OwnerWithFailOnUnknown.class)); + } + + @Test + public void selectAllOwnersWithFailOnUnknownShouldReturnAll() { + var orm = of(dataSource); + var owners = orm.entity(OwnerWithFailOnUnknown.class).select().getResultList(); + assertEquals(10, owners.size()); + } + + @Test + public void selectAllOwnersWithFailOnMissingShouldReturnAll() { + var orm = of(dataSource); + var owners = orm.entity(OwnerWithFailOnMissing.class).select().getResultList(); + assertEquals(10, owners.size()); + } + + @Test + public void updateOwnerJsonFieldShouldPersistChanges() { + var orm = of(dataSource); + var repository = orm.entity(Owner.class); + var owner = repository.getById(1); + var newAddress = new Address("100 Main St", "Springfield"); + repository.update(owner.toBuilder().address(newAddress).build()); + var updated = repository.getById(1); + assertEquals("100 Main St", updated.address().address()); + assertEquals("Springfield", updated.address().city()); + } + + @Test + public void updateOwnerNullableAddressToNullAndBackShouldRoundTrip() { + var orm = of(dataSource); + var repository = orm.entity(OwnerWithNullableAddress.class); + var address = new Address("test", "city"); + var owner = OwnerWithNullableAddress.builder() + .firstName("Round") + .lastName("Trip") + .address(address) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertNotNull(inserted.address()); + + // Update to null address. + repository.update(inserted.toBuilder().address(null).build()); + var withNullAddress = repository.getById(inserted.id()); + assertNull(withNullAddress.address()); + + // Update back to non-null address. + repository.update(withNullAddress.toBuilder().address(new Address("new", "addr")).build()); + var restored = repository.getById(inserted.id()); + assertNotNull(restored.address()); + assertEquals("new", restored.address().address()); + } + + @Test + public void selectNullableAddressWithNullValueShouldReturnNull() { + var orm = of(dataSource); + var repository = orm.entity(OwnerWithNullableAddress.class); + var owner = OwnerWithNullableAddress.builder() + .firstName("Direct") + .lastName("Null") + .address(null) + .telephone("555") + .build(); + repository.insert(owner); + + var allOwners = repository.select().getResultList(); + long nullAddressCount = allOwners.stream().filter(o -> o.address() == null).count(); + assertTrue(nullAddressCount >= 1); + } +} diff --git a/storm-jackson3/src/test/java/st/orm/jackson/StormModuleTest.java b/storm-jackson3/src/test/java/st/orm/jackson/StormModuleTest.java new file mode 100644 index 000000000..3944cc075 --- /dev/null +++ b/storm-jackson3/src/test/java/st/orm/jackson/StormModuleTest.java @@ -0,0 +1,792 @@ +package st.orm.jackson; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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 jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.List; +import java.util.Map; +import java.util.Set; +import st.orm.Data; +import st.orm.Entity; +import st.orm.PK; +import st.orm.Projection; +import st.orm.Ref; +import st.orm.core.spi.RefFactory; +import tools.jackson.databind.DatabindException; +import tools.jackson.databind.json.JsonMapper; + +class StormModuleTest { + + public record SimpleEntity(@PK Integer id, @Nonnull String name) + implements Entity {} + + public record SimpleProjection(@PK Integer id, @Nonnull String label) + implements Projection {} + + public record EntityHolder(@Nullable Ref entity) {} + + public record ProjectionHolder(@Nullable Ref projection) {} + + public record EntityListHolder(@Nullable List> entities) {} + + // -- RefSerializer tests -- + + @org.junit.jupiter.api.Test + void serializeUnloadedEntityRefToRawId() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = new EntityHolder(Ref.of(SimpleEntity.class, 42)); + var json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":42}", json); + } + + @org.junit.jupiter.api.Test + void serializeLoadedEntityRefWithEntityWrapper() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var entity = new SimpleEntity(7, "Test"); + var holder = new EntityHolder(Ref.of(entity)); + var json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":{\"@entity\":{\"id\":7,\"name\":\"Test\"}}}", json); + } + + @org.junit.jupiter.api.Test + void serializeLoadedProjectionRefWithProjectionWrapper() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var projection = new SimpleProjection(3, "Label"); + var holder = new ProjectionHolder(Ref.of(projection, 3)); + var json = mapper.writeValueAsString(holder); + assertEquals( + "{\"projection\":{\"@id\":3,\"@projection\":{\"id\":3,\"label\":\"Label\"}}}", + json); + } + + @org.junit.jupiter.api.Test + void serializeNullRefFieldToNull() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = new EntityHolder(null); + var json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":null}", json); + } + + // -- RefDeserializer tests -- + + @org.junit.jupiter.api.Test + void deserializeRawIdToDetachedRef() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"entity\":42}", EntityHolder.class); + assertNotNull(holder.entity()); + assertEquals(42, holder.entity().id()); + } + + @org.junit.jupiter.api.Test + void deserializeRawIdWithRefFactory() throws Exception { + RefFactory factory = new RefFactory() { + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Ref create(@Nonnull Class type, @Nonnull ID id) { + return (Ref) Ref.of((Class) type, id); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Ref create(@Nonnull T record, @Nonnull ID id) { + return (Ref) Ref.of((Class) record.getClass(), id); + } + }; + var mapper = JsonMapper.builder() + .addModule(new StormModule(factory)) + .build(); + var holder = mapper.readValue("{\"entity\":99}", EntityHolder.class); + assertNotNull(holder.entity()); + assertEquals(99, holder.entity().id()); + } + + @org.junit.jupiter.api.Test + void deserializeEntityObjectToLoadedRef() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var json = "{\"entity\":{\"@entity\":{\"id\":7,\"name\":\"Test\"}}}"; + var holder = mapper.readValue(json, EntityHolder.class); + assertNotNull(holder.entity()); + assertTrue(holder.entity().isLoaded()); + var entity = holder.entity().fetch(); + assertInstanceOf(SimpleEntity.class, entity); + assertEquals(7, entity.id()); + assertEquals("Test", entity.name()); + } + + @org.junit.jupiter.api.Test + void deserializeProjectionObjectToLoadedRef() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var json = "{\"projection\":{\"@id\":3,\"@projection\":{\"id\":3,\"label\":\"Label\"}}}"; + var holder = mapper.readValue(json, ProjectionHolder.class); + assertNotNull(holder.projection()); + assertTrue(holder.projection().isLoaded()); + var projection = holder.projection().fetch(); + assertInstanceOf(SimpleProjection.class, projection); + assertEquals(3, projection.id()); + assertEquals("Label", projection.label()); + } + + @org.junit.jupiter.api.Test + void deserializeNullRefFieldToNull() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"entity\":null}", EntityHolder.class); + assertNull(holder.entity()); + } + + @org.junit.jupiter.api.Test + void deserializeObjectWithoutEntityOrProjectionFieldShouldThrow() { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + assertThrows(DatabindException.class, + () -> mapper.readValue("{\"entity\":{\"unknown\":1}}", EntityHolder.class)); + } + + @org.junit.jupiter.api.Test + void deserializeStringIdToDetachedRef() throws Exception { + // Test VALUE_STRING token path + record StringIdEntity(@PK String id, @Nonnull String name) + implements Entity {} + record StringIdHolder(@Nullable Ref entity) {} + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"entity\":\"abc\"}", StringIdHolder.class); + assertNotNull(holder.entity()); + assertEquals("abc", holder.entity().id()); + } + + @org.junit.jupiter.api.Test + void deserializeListOfRefsWithNullElement() throws Exception { + // Test List> with null elements - exercises the container unwrapping + null handling + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var json = "{\"entities\":[42, null, 7]}"; + var holder = mapper.readValue(json, EntityListHolder.class); + assertNotNull(holder.entities()); + assertEquals(3, holder.entities().size()); + assertEquals(42, holder.entities().get(0).id()); + assertNull(holder.entities().get(1)); + assertEquals(7, holder.entities().get(2).id()); + } + + @org.junit.jupiter.api.Test + void roundTripEntityRefShouldPreserveLoadedState() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var entity = new SimpleEntity(5, "RoundTrip"); + var original = new EntityHolder(Ref.of(entity)); + var json = mapper.writeValueAsString(original); + var restored = mapper.readValue(json, EntityHolder.class); + assertNotNull(restored.entity()); + assertTrue(restored.entity().isLoaded()); + assertEquals(entity, restored.entity().fetch()); + } + + @org.junit.jupiter.api.Test + void roundTripProjectionRefShouldPreserveLoadedState() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var projection = new SimpleProjection(10, "Proj"); + var original = new ProjectionHolder(Ref.of(projection, 10)); + var json = mapper.writeValueAsString(original); + var restored = mapper.readValue(json, ProjectionHolder.class); + assertNotNull(restored.projection()); + assertTrue(restored.projection().isLoaded()); + assertEquals(projection, restored.projection().fetch()); + } + + // -- Tests for Data record without @PK (fallback deserialization paths) -- + + // Data record without @PK annotation, so resolvePkType returns null and fallback paths execute. + public record NoPkData(Integer id, @Nonnull String value) implements Data {} + + public record NoPkDataHolder(@Nullable Ref data) {} + + public record NoPkDataProjection(Integer id, @Nonnull String value) + implements Projection {} + + public record NoPkDataProjectionHolder(@Nullable Ref data) {} + + @org.junit.jupiter.api.Test + void deserializeIntIdFallbackForNoPkData() throws Exception { + // When pkType is null (no @PK on target), the fallback deserializeId path is used. + // This exercises the VALUE_NUMBER_INT fallback in deserializeId. + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"data\":42}", NoPkDataHolder.class); + assertNotNull(holder.data()); + assertEquals(42, ((Number) holder.data().id()).intValue()); + } + + @org.junit.jupiter.api.Test + void deserializeStringIdFallbackForNoPkData() throws Exception { + // Exercises the VALUE_STRING fallback in deserializeId. + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"data\":\"abc\"}", NoPkDataHolder.class); + assertNotNull(holder.data()); + assertEquals("abc", holder.data().id()); + } + + @org.junit.jupiter.api.Test + void deserializeFloatIdFallbackForNoPkData() throws Exception { + // Exercises the VALUE_NUMBER_FLOAT fallback in deserializeId. + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"data\":3.14}", NoPkDataHolder.class); + assertNotNull(holder.data()); + assertEquals(3.14, holder.data().id()); + } + + @org.junit.jupiter.api.Test + void deserializeLargeIntIdFallbackForNoPkData() throws Exception { + // Exercises the VALUE_NUMBER_INT fallback with a value > Integer.MAX_VALUE. + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + long largeValue = (long) Integer.MAX_VALUE + 1L; + var holder = mapper.readValue("{\"data\":" + largeValue + "}", NoPkDataHolder.class); + assertNotNull(holder.data()); + assertEquals(largeValue, holder.data().id()); + } + + @org.junit.jupiter.api.Test + void deserializeNegativeLargeIntIdFallbackForNoPkData() throws Exception { + // Exercises the VALUE_NUMBER_INT fallback with a value < Integer.MIN_VALUE. + // This covers the short-circuit branch when value < Integer.MIN_VALUE. + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + long negativeValue = (long) Integer.MIN_VALUE - 1L; + var holder = mapper.readValue("{\"data\":" + negativeValue + "}", NoPkDataHolder.class); + assertNotNull(holder.data()); + assertEquals(negativeValue, holder.data().id()); + } + + @org.junit.jupiter.api.Test + void deserializeProjectionObjectWithIntIdNodeFallbackForNoPk() throws Exception { + // Exercises the deserializeIdFromNode fallback path for a Projection without @PK. + // When pkType is null, the node.isInt() path is taken. + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var json = "{\"data\":{\"@id\":5,\"@projection\":{\"id\":5,\"value\":\"hello\"}}}"; + var holder = mapper.readValue(json, NoPkDataProjectionHolder.class); + assertNotNull(holder.data()); + assertTrue(holder.data().isLoaded()); + assertEquals(5, holder.data().id()); + } + + @org.junit.jupiter.api.Test + void deserializeProjectionObjectWithStringIdNodeFallbackForNoPk() throws Exception { + // Exercises the deserializeIdFromNode node.isTextual() path. + record NoPkStringProjection(String id, @Nonnull String value) + implements Projection {} + record NoPkStringProjectionHolder(@Nullable Ref data) {} + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var json = "{\"data\":{\"@id\":\"abc\",\"@projection\":{\"id\":\"abc\",\"value\":\"hello\"}}}"; + var holder = mapper.readValue(json, NoPkStringProjectionHolder.class); + assertNotNull(holder.data()); + assertTrue(holder.data().isLoaded()); + assertEquals("abc", holder.data().id()); + } + + @org.junit.jupiter.api.Test + void deserializeProjectionObjectWithLongIdNodeFallbackForNoPk() throws Exception { + // Exercises the deserializeIdFromNode node.isLong() path. + record NoPkLongProjection(Long id, @Nonnull String value) + implements Projection {} + record NoPkLongProjectionHolder(@Nullable Ref data) {} + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + long largeValue = (long) Integer.MAX_VALUE + 1L; + var json = "{\"data\":{\"@id\":" + largeValue + + ",\"@projection\":{\"id\":" + largeValue + ",\"value\":\"hello\"}}}"; + var holder = mapper.readValue(json, NoPkLongProjectionHolder.class); + assertNotNull(holder.data()); + assertTrue(holder.data().isLoaded()); + assertEquals(largeValue, holder.data().id()); + } + + @org.junit.jupiter.api.Test + void deserializeProjectionObjectWithDoubleIdNodeFallbackForNoPk() throws Exception { + // Exercises the deserializeIdFromNode node.isDouble() path. + record NoPkDoubleProjection(Double id, @Nonnull String value) + implements Projection {} + record NoPkDoubleProjectionHolder(@Nullable Ref data) {} + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var json = "{\"data\":{\"@id\":3.14,\"@projection\":{\"id\":3.14,\"value\":\"hello\"}}}"; + var holder = mapper.readValue(json, NoPkDoubleProjectionHolder.class); + assertNotNull(holder.data()); + assertTrue(holder.data().isLoaded()); + assertEquals(3.14, holder.data().id()); + } + + @org.junit.jupiter.api.Test + void constructorWithRefFactoryShouldPassThroughToSupplier() throws Exception { + RefFactory factory = new RefFactory() { + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Ref create(@Nonnull Class type, @Nonnull ID id) { + return (Ref) Ref.of((Class) type, id); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Ref create(@Nonnull T record, @Nonnull ID id) { + return (Ref) Ref.of((Class) record.getClass(), id); + } + }; + var module = new StormModule(factory); + var mapper = JsonMapper.builder() + .addModule(module) + .build(); + var holder = mapper.readValue("{\"entity\":123}", EntityHolder.class); + assertNotNull(holder.entity()); + assertEquals(123, holder.entity().id()); + } + + // -- Additional record types for broader coverage -- + + public record StringIdEntity(@PK String id, @Nonnull String name) implements Entity {} + + public record LongIdEntity(@PK Long id, @Nonnull String name) implements Entity {} + + public record DoubleIdEntity(@PK Double id, @Nonnull String name) implements Entity {} + + public record StringIdHolder(@Nonnull Ref entity) {} + + public record LongIdHolder(@Nonnull Ref entity) {} + + public record DoubleIdHolder(@Nonnull Ref entity) {} + + public record RefSetHolder(@Nonnull Set> entities) {} + + public record RefMapHolder(@Nonnull Map> entities) {} + + public record NoPkRefListHolder(@Nonnull List> data) {} + + public record NoPkProjection(String label) implements Projection {} + + public record NoPkProjectionHolder(@Nullable Ref projection) {} + + public record DataRefHolder(@Nullable Ref data) {} + + // -- Constructor with null RefFactory -- + + @org.junit.jupiter.api.Test + void constructorWithNullRefFactoryShouldCreateModuleWithDetachedRefs() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule((RefFactory) null)) + .build(); + var holder = mapper.readValue("{\"entity\":1}", EntityHolder.class); + assertNotNull(holder.entity()); + assertEquals(1, holder.entity().id()); + } + + @org.junit.jupiter.api.Test + void constructorWithNullSupplierResultShouldCreateDetachedRef() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule(() -> null)) + .build(); + var holder = mapper.readValue("{\"entity\":7}", EntityHolder.class); + assertNotNull(holder.entity()); + assertEquals(7, holder.entity().id()); + } + + // -- Serialization for different ID types -- + + @org.junit.jupiter.api.Test + void unloadedRefWithStringIdShouldSerializeAsStringValue() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = new StringIdHolder(Ref.of(StringIdEntity.class, "abc-123")); + var json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":\"abc-123\"}", json); + } + + @org.junit.jupiter.api.Test + void unloadedRefWithLongIdShouldSerializeAsLongValue() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = new LongIdHolder(Ref.of(LongIdEntity.class, 9999999999L)); + var json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":9999999999}", json); + } + + // -- Deserialization for different ID types -- + + @org.junit.jupiter.api.Test + void rawLongIdShouldDeserializeToUnloadedRef() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"entity\":9999999999}", LongIdHolder.class); + assertNotNull(holder.entity()); + assertEquals(9999999999L, holder.entity().id()); + } + + // -- Deserialization error paths -- + + @org.junit.jupiter.api.Test + void projectionObjectWithoutIdFieldShouldThrowException() { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + assertThrows(DatabindException.class, + () -> mapper.readValue("{\"projection\":{\"@projection\":{\"id\":5,\"label\":\"Test\"}}}", ProjectionHolder.class)); + } + + @org.junit.jupiter.api.Test + void booleanTokenShouldThrowExceptionDuringRefDeserialization() { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + assertThrows(DatabindException.class, + () -> mapper.readValue("{\"entity\":true}", EntityHolder.class)); + } + + @org.junit.jupiter.api.Test + void arrayTokenShouldThrowExceptionDuringRefDeserialization() { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + assertThrows(DatabindException.class, + () -> mapper.readValue("{\"entity\":[1,2,3]}", EntityHolder.class)); + } + + // -- Set and Map container types -- + + @org.junit.jupiter.api.Test + void refInSetShouldDeserializeCorrectly() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"entities\":[1,2,3]}", RefSetHolder.class); + assertEquals(3, holder.entities().size()); + } + + @org.junit.jupiter.api.Test + void refInMapValueShouldDeserializeCorrectly() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"entities\":{\"a\":1,\"b\":2}}", RefMapHolder.class); + assertEquals(2, holder.entities().size()); + assertEquals(1, holder.entities().get("a").id()); + assertEquals(2, holder.entities().get("b").id()); + } + + @org.junit.jupiter.api.Test + void refSetSerializationShouldRoundTrip() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var refs = Set.of( + Ref.of(SimpleEntity.class, 1), + Ref.of(SimpleEntity.class, 2)); + var holder = new RefSetHolder(refs); + var json = mapper.writeValueAsString(holder); + assertNotNull(json); + var deserialized = mapper.readValue(json, RefSetHolder.class); + assertEquals(2, deserialized.entities().size()); + } + + @org.junit.jupiter.api.Test + void refMapSerializationShouldWork() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var refs = Map.of( + "a", Ref.of(SimpleEntity.class, 1), + "b", Ref.of(SimpleEntity.class, 2)); + var holder = new RefMapHolder(refs); + var json = mapper.writeValueAsString(holder); + assertNotNull(json); + } + + // -- NoPk projection node fallback paths -- + + @org.junit.jupiter.api.Test + void noPkProjectionWithIntIdNodeShouldDeserializeViaNodeFallback() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue( + "{\"projection\":{\"@id\":42,\"@projection\":{\"label\":\"Test\"}}}", + NoPkProjectionHolder.class); + assertNotNull(holder.projection()); + assertEquals(42, holder.projection().id()); + } + + @org.junit.jupiter.api.Test + void noPkProjectionWithLongIdNodeShouldDeserializeViaNodeFallback() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var json = "{\"projection\":{\"@id\":9999999999,\"@projection\":{\"label\":\"Test\"}}}"; + var holder = mapper.readValue(json, NoPkProjectionHolder.class); + assertNotNull(holder.projection()); + assertEquals(9999999999L, holder.projection().id()); + } + + @org.junit.jupiter.api.Test + void noPkProjectionWithDoubleIdNodeShouldDeserializeViaNodeFallback() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue( + "{\"projection\":{\"@id\":3.14,\"@projection\":{\"label\":\"Test\"}}}", + NoPkProjectionHolder.class); + assertNotNull(holder.projection()); + assertEquals(3.14, holder.projection().id()); + } + + @org.junit.jupiter.api.Test + void noPkProjectionWithStringIdNodeShouldDeserializeViaNodeFallback() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue( + "{\"projection\":{\"@id\":\"abc\",\"@projection\":{\"label\":\"Test\"}}}", + NoPkProjectionHolder.class); + assertNotNull(holder.projection()); + assertEquals("abc", holder.projection().id()); + } + + @org.junit.jupiter.api.Test + void noPkProjectionWithObjectIdNodeShouldDeserializeViaObjectFallback() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue( + "{\"projection\":{\"@id\":{\"key\":\"val\"},\"@projection\":{\"label\":\"Test\"}}}", + NoPkProjectionHolder.class); + assertNotNull(holder.projection()); + assertNotNull(holder.projection().id()); + } + + // -- Entity via @projection path and non-Entity via @entity path -- + + @org.junit.jupiter.api.Test + void projectionPathWithEntityDataShouldCreateEntityRef() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var json = "{\"entity\":{\"@id\":1,\"@projection\":{\"id\":1,\"name\":\"Alice\"}}}"; + var holder = mapper.readValue(json, EntityHolder.class); + assertNotNull(holder.entity()); + assertEquals(1, holder.entity().id()); + assertNotNull(holder.entity().getOrNull()); + } + + @org.junit.jupiter.api.Test + void entityPathWithNonEntityDataShouldThrowException() { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + assertThrows(DatabindException.class, + () -> mapper.readValue("{\"data\":{\"@entity\":{\"id\":42,\"value\":\"test\"}}}", DataRefHolder.class)); + } + + @org.junit.jupiter.api.Test + void projectionPathWithPlainDataAndIdShouldCreateUnloadedRef() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue( + "{\"data\":{\"@id\":42,\"@projection\":{\"id\":42,\"value\":\"test\"}}}", + DataRefHolder.class); + assertNotNull(holder.data()); + assertEquals(42, ((Number) holder.data().id()).intValue()); + } + + // -- NoPk data serialization and list tests -- + + @org.junit.jupiter.api.Test + void unloadedNoPkRefShouldSerializeAsRawId() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = new NoPkDataHolder(Ref.of(NoPkData.class, 42)); + var json = mapper.writeValueAsString(holder); + assertTrue(json.contains("42")); + } + + @org.junit.jupiter.api.Test + void noPkDataSerializerShouldHandleMissingPkTypeGracefully() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = new DataRefHolder(Ref.of(NoPkData.class, "text-id")); + var json = mapper.writeValueAsString(holder); + assertTrue(json.contains("text-id")); + } + + @org.junit.jupiter.api.Test + void noPkRefListWithMixedIdTypesShouldDeserializeViaFallback() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"data\":[1,\"two\",3]}", NoPkRefListHolder.class); + assertEquals(3, holder.data().size()); + assertEquals(1, ((Number) holder.data().get(0).id()).intValue()); + assertEquals("two", holder.data().get(1).id()); + assertEquals(3, ((Number) holder.data().get(2).id()).intValue()); + } + + @org.junit.jupiter.api.Test + void noPkRefWithNullIdInListShouldReturnNullRef() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"data\":[null, 1]}", NoPkRefListHolder.class); + assertEquals(2, holder.data().size()); + assertNull(holder.data().get(0)); + assertNotNull(holder.data().get(1)); + } + + // -- Double ID round-trip -- + + @org.junit.jupiter.api.Test + void doubleIdRefShouldRoundTrip() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = new DoubleIdHolder(Ref.of(DoubleIdEntity.class, 3.14)); + var json = mapper.writeValueAsString(holder); + assertEquals("{\"entity\":3.14}", json); + var deserialized = mapper.readValue(json, DoubleIdHolder.class); + assertEquals(3.14, deserialized.entity().id()); + } + + @org.junit.jupiter.api.Test + void loadedDoubleIdEntityRefShouldRoundTrip() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var entity = new DoubleIdEntity(2.71, "Euler"); + var holder = new DoubleIdHolder(Ref.of(entity)); + var json = mapper.writeValueAsString(holder); + assertTrue(json.contains("@entity")); + var deserialized = mapper.readValue(json, DoubleIdHolder.class); + assertEquals(2.71, deserialized.entity().id()); + } + + // -- Additional round-trip tests -- + + @org.junit.jupiter.api.Test + void stringIdRefShouldRoundTrip() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = new StringIdHolder(Ref.of(StringIdEntity.class, "my-id")); + var json = mapper.writeValueAsString(holder); + var deserialized = mapper.readValue(json, StringIdHolder.class); + assertEquals(holder, deserialized); + } + + @org.junit.jupiter.api.Test + void loadedStringIdEntityRefShouldRoundTrip() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var entity = new StringIdEntity("abc", "Test"); + var holder = new StringIdHolder(Ref.of(entity)); + var json = mapper.writeValueAsString(holder); + var deserialized = mapper.readValue(json, StringIdHolder.class); + assertEquals(holder, deserialized); + } + + @org.junit.jupiter.api.Test + void loadedLongIdEntityRefShouldRoundTrip() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var entity = new LongIdEntity(999L, "Test"); + var holder = new LongIdHolder(Ref.of(entity)); + var json = mapper.writeValueAsString(holder); + var deserialized = mapper.readValue(json, LongIdHolder.class); + assertEquals(holder, deserialized); + } + + // -- Null projection ref -- + + @org.junit.jupiter.api.Test + void nullProjectionRefShouldSerializeToNull() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = new ProjectionHolder(null); + var json = mapper.writeValueAsString(holder); + assertEquals("{\"projection\":null}", json); + } + + @org.junit.jupiter.api.Test + void nullProjectionRefShouldDeserializeFromNull() throws Exception { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + var holder = mapper.readValue("{\"projection\":null}", ProjectionHolder.class); + assertNull(holder.projection()); + } + + // -- Raw Ref without type info -- + + @SuppressWarnings("rawtypes") + @org.junit.jupiter.api.Test + void rawRefWithoutTypeInfoShouldThrowOnDeserialization() { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + assertThrows(DatabindException.class, + () -> mapper.readValue("42", Ref.class)); + } + + // -- NoPk boolean error -- + + @org.junit.jupiter.api.Test + void noPkDataWithBooleanShouldThrowExceptionViaFallbackPath() { + var mapper = JsonMapper.builder() + .addModule(new StormModule()) + .build(); + assertThrows(DatabindException.class, + () -> mapper.readValue("{\"data\":true}", NoPkDataHolder.class)); + } +} diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/CoverageBoostTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/CoverageBoostTest.kt new file mode 100644 index 000000000..a73705405 --- /dev/null +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/CoverageBoostTest.kt @@ -0,0 +1,561 @@ +package st.orm.spring + +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.config.BeanDefinitionHolder +import org.springframework.beans.factory.config.DependencyDescriptor +import org.springframework.beans.factory.support.AutowireCandidateResolver +import org.springframework.beans.factory.support.DefaultListableBeanFactory +import org.springframework.beans.factory.support.RootBeanDefinition +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.jdbc.Sql +import st.orm.repository.countAll +import st.orm.repository.deleteAll +import st.orm.repository.exists +import st.orm.spring.impl.RepositoryAopAutoConfiguration +import st.orm.spring.impl.ResolverRegistration +import st.orm.spring.model.City +import st.orm.spring.model.Pet +import st.orm.spring.model.Visit +import st.orm.template.ORMTemplate +import st.orm.template.TransactionIsolation.REPEATABLE_READ +import st.orm.template.TransactionPropagation.NESTED +import st.orm.template.TransactionPropagation.REQUIRED +import st.orm.template.TransactionPropagation.REQUIRES_NEW +import st.orm.template.UnexpectedRollbackException +import st.orm.template.setGlobalTransactionOptions +import st.orm.template.transactionBlocking + +/** + * Additional coverage tests targeting uncovered methods in storm-kotlin-spring: + * - SpringTransactionContext: getEntityCache, findEntityCache, isRollbackOnly branches + * - RepositoryAutowireCandidateResolver: getQualifierValue branches, register + * - ResolverRegistration: constructor + * - RepositoryBeanFactoryPostProcessor: getOrmTemplateBeanName, getRepositoryBasePackages + * - RepositoryAopAutoConfiguration: constructor, repositoryProxyingPostProcessor + */ +@ContextConfiguration(classes = [IntegrationConfig::class]) +@EnableTransactionIntegration +@SpringBootTest +@Sql("/data.sql") +open class CoverageBoostTest( + @Autowired val orm: ORMTemplate, +) { + + @AfterEach + fun resetDefaults() { + setGlobalTransactionOptions( + propagation = REQUIRED, + isolation = null, + timeoutSeconds = null, + readOnly = false, + ) + } + + // ====================================================================== + // SpringTransactionContext: entity cache (getEntityCache, findEntityCache) + // ====================================================================== + + @Test + fun `entity cache should be created for each entity type under REPEATABLE_READ`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + // Load City and verify caching + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + (city1 === city2).shouldBeTrue() + + // Load Visit entities (creates their cache) + val visit1 = orm.entity(Visit::class).select().where(1).singleResult + val visit2 = orm.entity(Visit::class).select().where(1).singleResult + (visit1 === visit2).shouldBeTrue() + } + } + + @Test + fun `entity cache should be isolated per entity type under REPEATABLE_READ`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + (city1 === city2).shouldBeTrue() + + val visits = orm.entity(Visit::class).select().resultList + visits.shouldNotBeNull() + } + } + + @Test + fun `DML should invalidate entity cache for affected type`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + // Load City into cache + val city1 = orm.entity(City::class).select().where(1).singleResult + val city2 = orm.entity(City::class).select().where(1).singleResult + (city1 === city2).shouldBeTrue() + + // Insert a new city: this should invalidate the City entity cache + orm.entity(City::class).insert(City(name = "CoverageVille")) + + // After cache invalidation, reloading should produce a new object + val city3 = orm.entity(City::class).select().where(1).singleResult + city3.name shouldBe city1.name + } + } + + @Test + fun `DML on Visit should not invalidate City cache`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + // Load City into cache + val city1 = orm.entity(City::class).select().where(1).singleResult + + // Load Visit into cache + val visit1 = orm.entity(Visit::class).select().where(1).singleResult + + // Delete all visits: should invalidate Visit cache but not City cache + orm.deleteAll() + + // City cache should still be intact + val city2 = orm.entity(City::class).select().where(1).singleResult + (city1 === city2).shouldBeTrue() + } + } + + // ====================================================================== + // SpringTransactionContext: isRollbackOnly (0% covered) + // ====================================================================== + + @Test + fun `isRollbackOnly should be false before setRollbackOnly`(): Unit = runBlocking { + transactionBlocking { + orm.entity(City::class).count() shouldBe 6 + // Check isRollbackOnly through the Transaction interface + isRollbackOnly.shouldBeFalse() + } + } + + @Test + fun `isRollbackOnly should be true after setRollbackOnly with DB access`(): Unit = runBlocking { + transactionBlocking { + orm.entity(City::class).count() shouldBe 6 + setRollbackOnly() + isRollbackOnly.shouldBeTrue() + } + } + + @Test + fun `isRollbackOnly should be true after setRollbackOnly without DB access`(): Unit = runBlocking { + transactionBlocking { + setRollbackOnly() + // isRollbackOnly should be true even without DB access + // This exercises the branch where transactionStatus is null + isRollbackOnly.shouldBeTrue() + } + } + + @Test + fun `isRollbackOnly should be true after inner setRollbackOnly`(): Unit = runBlocking { + // Inner REQUIRED joins the outer transaction; marking rollback-only in the inner + // poisons the outer, causing UnexpectedRollbackException on outer commit. + assertThrows { + transactionBlocking { + transactionBlocking(REQUIRED) { + orm.entity(City::class).count() shouldBe 6 + setRollbackOnly() + isRollbackOnly.shouldBeTrue() + } + } + } + } + + // ====================================================================== + // RepositoryAutowireCandidateResolver: register (0% covered) + // ====================================================================== + + @Test + fun `register should install RepositoryAutowireCandidateResolver in bean factory`() { + val beanFactory = DefaultListableBeanFactory() + RepositoryBeanFactoryPostProcessor.RepositoryAutowireCandidateResolver.register(beanFactory) + (beanFactory.autowireCandidateResolver is RepositoryBeanFactoryPostProcessor.RepositoryAutowireCandidateResolver).shouldBeTrue() + } + + // ====================================================================== + // RepositoryAutowireCandidateResolver: getQualifierValue meta-annotation + // ====================================================================== + + @Qualifier + @Retention(AnnotationRetention.RUNTIME) + @Target(AnnotationTarget.FIELD) + annotation class MetaQualified(val value: String = "") + + @Test + fun `getQualifierValue should extract value from meta-Qualifier annotation`() { + // Create a resolver with a mock delegate + val delegate = mock(AutowireCandidateResolver::class.java) + val resolver = + RepositoryBeanFactoryPostProcessor.RepositoryAutowireCandidateResolver(delegate) + + // Create a mock DependencyDescriptor with our meta-annotated annotation + val descriptor = mock(DependencyDescriptor::class.java) + val metaAnnotation = MetaQualified::class.java.getAnnotation(Qualifier::class.java) + metaAnnotation.shouldNotBeNull() + + // Create a BeanDefinitionHolder with a qualifier attribute + val beanDefinition = RootBeanDefinition(String::class.java) + beanDefinition.setAttribute("qualifier", "") + val holder = BeanDefinitionHolder(beanDefinition, "testBean") + + // Mock delegate to reject the candidate, forcing getRequiredQualifier path + `when`(delegate.isAutowireCandidate(holder, descriptor)).thenReturn(false) + + // Use a real annotation that is meta-annotated with @Qualifier + val annotations = arrayOf(MetaQualified("testValue")) + `when`(descriptor.annotations).thenReturn(annotations) + + // The resolver should extract the qualifier value from the meta-annotation + // The getQualifierValue method will check: + // 1. annotation is Qualifier -> NO (it's MetaQualified) + // 2. annotation.annotationClass.java.getAnnotation(Qualifier) -> returns Qualifier + // 3. metadata?.value -> returns "" + // The holder has qualifier attribute "" which matches, so isAutowireCandidate returns true + resolver.isAutowireCandidate(holder, descriptor).shouldBeTrue() + } + + @Test + fun `getQualifierValue should return null for non-qualifier annotation`() { + val delegate = mock(AutowireCandidateResolver::class.java) + val resolver = + RepositoryBeanFactoryPostProcessor.RepositoryAutowireCandidateResolver(delegate) + + val descriptor = mock(DependencyDescriptor::class.java) + val beanDefinition = RootBeanDefinition(String::class.java) + val holder = BeanDefinitionHolder(beanDefinition, "testBean") + + `when`(delegate.isAutowireCandidate(holder, descriptor)).thenReturn(false) + + // Use an annotation that is NOT meta-annotated with @Qualifier + val annotations = arrayOf(Override()) + `when`(descriptor.annotations).thenReturn(annotations) + + // Without a qualifier annotation, isAutowireCandidate should return false + resolver.isAutowireCandidate(holder, descriptor).shouldBeFalse() + } + + // ====================================================================== + // ResolverRegistration: constructor + // ====================================================================== + + @Test + fun `ResolverRegistration should be instantiable with bean factory`() { + val beanFactory = DefaultListableBeanFactory() + val registration = ResolverRegistration(beanFactory) + registration.shouldNotBeNull() + } + + // ====================================================================== + // RepositoryBeanFactoryPostProcessor: uncovered properties + // ====================================================================== + + @Test + fun `ormTemplateBeanName default should be null`() { + val processor = RepositoryBeanFactoryPostProcessor() + processor.ormTemplateBeanName.shouldBeNull() + } + + @Test + fun `repositoryBasePackages default should be empty`() { + val processor = RepositoryBeanFactoryPostProcessor() + processor.repositoryBasePackages shouldBe emptyArray() + } + + @Test + fun `postProcessBeanFactory with empty base packages should not register`() { + val processor = RepositoryBeanFactoryPostProcessor() + val beanFactory = DefaultListableBeanFactory() + processor.postProcessBeanFactory(beanFactory) + } + + // ====================================================================== + // RepositoryAopAutoConfiguration (0% covered) + // ====================================================================== + + @Test + fun `RepositoryAopAutoConfiguration should be instantiable`() { + val configuration = RepositoryAopAutoConfiguration() + configuration.shouldNotBeNull() + } + + @Test + fun `RepositoryAopAutoConfiguration companion should provide repositoryProxyingPostProcessor`() { + val postProcessor = RepositoryAopAutoConfiguration.repositoryProxyingPostProcessor() + postProcessor.shouldNotBeNull() + } + + // ====================================================================== + // SpringTransactionContext: commit and rollback edge cases + // ====================================================================== + + @Test + fun `commit with no DB access and timeout should handle timeout check`(): Unit = runBlocking { + transactionBlocking(timeoutSeconds = 30) { + // No DB access, exercises commit path where transactionStatus is null + } + } + + @Test + fun `rollback after exception should handle cleanup`(): Unit = runBlocking { + assertThrows { + transactionBlocking { + orm.entity(City::class).count() shouldBe 6 + throw RuntimeException("forced rollback") + } + } + } + + @Test + fun `rollback after exception with timeout should handle cleanup`(): Unit = runBlocking { + assertThrows { + transactionBlocking(timeoutSeconds = 30) { + orm.entity(City::class).count() shouldBe 6 + throw RuntimeException("forced rollback with timeout") + } + } + } + + @Test + fun `rollback with no DB access should handle cleanup`(): Unit = runBlocking { + assertThrows { + transactionBlocking { + throw RuntimeException("rollback without DB access") + } + } + } + + @Test + fun `rollback with no DB access and timeout should handle cleanup`(): Unit = runBlocking { + assertThrows { + transactionBlocking(timeoutSeconds = 30) { + throw RuntimeException("rollback without DB access but with timeout") + } + } + } + + @Test + fun `nested REQUIRED transactions should share connection`(): Unit = runBlocking { + transactionBlocking { + val count1 = orm.entity(City::class).count() + transactionBlocking(REQUIRED) { + val count2 = orm.entity(City::class).count() + count1 shouldBe count2 + } + } + } + + // ====================================================================== + // SpringTransactionContext: UnexpectedRollbackException in commit path + // ====================================================================== + + @Test + fun `inner setRollbackOnly should cause UnexpectedRollbackException on outer commit`(): Unit = runBlocking { + assertThrows { + transactionBlocking { + orm.entity(City::class).count() shouldBe 6 + transactionBlocking(REQUIRED) { + orm.entity(City::class).count() shouldBe 6 + setRollbackOnly() + } + // Outer tries to commit but inner marked rollback-only + } + } + } + + // ====================================================================== + // SpringTransactionContext: NESTED rollback clears entity cache + // ====================================================================== + + @Test + fun `NESTED rollback should clear shared entity cache`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(NESTED) { + orm.deleteAll() + setRollbackOnly() + } + // After nested rollback, entity cache should have been cleared + val city2 = orm.entity(City::class).select().where(1).singleResult + (city1 === city2).shouldBeFalse() + city1.name shouldBe city2.name + } + } + + // ====================================================================== + // SpringTransactionContext: REQUIRES_NEW with REPEATABLE_READ isolation + // ====================================================================== + + @Test + fun `REQUIRES_NEW should use separate entity cache`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + transactionBlocking(REQUIRES_NEW, isolation = REPEATABLE_READ) { + val city2 = orm.entity(City::class).select().where(1).singleResult + // REQUIRES_NEW uses separate cache so objects differ + (city1 === city2).shouldBeFalse() + city1.name shouldBe city2.name + } + } + } + + // ====================================================================== + // SpringConnectionProviderImpl: getConnection with transaction context + // ====================================================================== + + @Test + fun `connection within transaction should be managed by Spring`(): Unit = runBlocking { + transactionBlocking { + val count1 = orm.entity(City::class).count() + val count2 = orm.entity(Visit::class).count() + count1 shouldBe 6 + count2 shouldBe 14 + } + } + + @Test + fun `connection outside transaction should still work`() { + val count = orm.entity(City::class).count() + count shouldBe 6 + } + + // ====================================================================== + // SpringTransactionContext: transaction with multiple entity types and DML + // ====================================================================== + + @Test + fun `transaction with entity cache and multiple entity types`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city = orm.entity(City::class).select().where(1).singleResult + val visits = orm.entity(Visit::class).select().resultList + + val cityAgain = orm.entity(City::class).select().where(1).singleResult + (city === cityAgain).shouldBeTrue() + + visits.size shouldBe 14 + } + } + + @Test + fun `setRollbackOnly with DB access should mark transaction`(): Unit = runBlocking { + transactionBlocking { + orm.entity(City::class).count() shouldBe 6 + setRollbackOnly() + orm.entity(City::class).count() shouldBe 6 + } + } + + @Test + fun `transaction with readOnly and timeout should work`(): Unit = runBlocking { + transactionBlocking(readOnly = true, timeoutSeconds = 30) { + orm.entity(City::class).count() shouldBe 6 + } + } + + @Test + fun `REPEATABLE_READ should cache entities correctly`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val city1 = orm.entity(City::class).select().where(1).singleResult + city1.name shouldBe "Sun Paririe" + } + } + + // ====================================================================== + // SpringTransactionContext: Pet entity with REPEATABLE_READ + // (exercises entity cache for additional entity types) + // ====================================================================== + + @Test + fun `Pet entity should be cacheable under REPEATABLE_READ`(): Unit = runBlocking { + transactionBlocking(isolation = REPEATABLE_READ) { + val pet1 = orm.entity(Pet::class).select().where(1).singleResult + val pet2 = orm.entity(Pet::class).select().where(1).singleResult + (pet1 === pet2).shouldBeTrue() + } + } + + // ====================================================================== + // SpringTransactionContext: countAll within various propagation modes + // ====================================================================== + + @Test + fun `countAll within REQUIRES_NEW should work`(): Unit = runBlocking { + transactionBlocking { + transactionBlocking(REQUIRES_NEW) { + orm.countAll() shouldBe 6 + } + } + } + + @Test + fun `exists within NESTED should work`(): Unit = runBlocking { + transactionBlocking { + transactionBlocking(NESTED) { + orm.exists().shouldBeTrue() + } + } + } + + // ====================================================================== + // RepositoryBeanFactoryPostProcessor: defaultClassLoader without resourceLoader + // ====================================================================== + + @Test + fun `postProcessBeanFactory without resourceLoader should use default classloader`() { + // Create a processor without calling setResourceLoader. + // This exercises the defaultClassLoader fallback chain: + // resourceLoader?.classLoader ?: ClassUtils.getDefaultClassLoader() ?: ClassLoader.getSystemClassLoader() + val processor = object : RepositoryBeanFactoryPostProcessor() { + override val repositoryBasePackages: Array get() = arrayOf("st.orm.spring.repository") + } + // Register an ORMTemplate bean in the factory + val beanFactory = DefaultListableBeanFactory() + beanFactory.registerSingleton("ormTemplate", orm) + // This should work without a resourceLoader, using the default classloader + processor.postProcessBeanFactory(beanFactory) + } + + // ====================================================================== + // SpringTransactionContext: commit with expired deadline + // ====================================================================== + + @Test + fun `commit with expired deadline should trigger rollback path`(): Unit = runBlocking { + // When a transaction has a very short timeout and the callback takes longer, + // the commit method detects the expired deadline and redirects to rollback. + assertThrows { + transactionBlocking(timeoutSeconds = 1) { + orm.entity(City::class).count() shouldBe 6 + Thread.sleep(1500) + // On commit, deadline is expired -> rollback path + } + } + } + + @Test + fun `NESTED commit after inner timeout should propagate timeout`(): Unit = runBlocking { + assertThrows { + transactionBlocking(timeoutSeconds = 1) { + transactionBlocking(NESTED) { + orm.entity(City::class).count() shouldBe 6 + Thread.sleep(1500) + } + } + } + } +} diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/NullBeanNameRepositoryTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/NullBeanNameRepositoryTest.kt new file mode 100644 index 000000000..44e0df400 --- /dev/null +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/NullBeanNameRepositoryTest.kt @@ -0,0 +1,40 @@ +package st.orm.spring + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestConstructor +import org.springframework.test.context.TestConstructor.AutowireMode.ALL +import org.springframework.test.context.jdbc.Sql +import st.orm.spring.repository.VisitRepository + +/** + * Tests for [RepositoryBeanFactoryPostProcessor] with null [ormTemplateBeanName]. + * This exercises the getBeanORMTemplate branch where beanName is null, + * falling back to getBean(ORMTemplate::class.java) by type. + */ +@Suppress("SpringJavaInjectionPointsAutowiringInspection") +@ContextConfiguration(classes = [IntegrationConfig::class]) +@Import(NullBeanNameRepositoryTest.NullBeanNamePostProcessor::class) +@TestConstructor(autowireMode = ALL) +@SpringBootTest +@Sql("/data.sql") +class NullBeanNameRepositoryTest( + val visitRepository: VisitRepository, +) { + + @Configuration + open class NullBeanNamePostProcessor : RepositoryBeanFactoryPostProcessor() { + // ormTemplateBeanName is null (default), so getBeanORMTemplate + // will use beanFactory.getBean(ORMTemplate::class.java) by type lookup. + override val repositoryBasePackages: Array get() = arrayOf("st.orm.spring.repository") + } + + @Test + fun `repository with null ormTemplateBeanName should resolve by type`() { + visitRepository.count() shouldBe 14 + } +} diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryAutowireCandidateResolverTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryAutowireCandidateResolverTest.kt new file mode 100644 index 000000000..e7cc39f1b --- /dev/null +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/RepositoryAutowireCandidateResolverTest.kt @@ -0,0 +1,53 @@ +package st.orm.spring + +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.config.DependencyDescriptor +import org.springframework.beans.factory.support.AutowireCandidateResolver + +/** + * Unit tests for [RepositoryBeanFactoryPostProcessor.RepositoryAutowireCandidateResolver] + * delegation methods that are not exercised through integration tests. + */ +class RepositoryAutowireCandidateResolverTest { + + private val delegate = mock(AutowireCandidateResolver::class.java) + private val resolver = RepositoryBeanFactoryPostProcessor.RepositoryAutowireCandidateResolver(delegate) + + @Test + fun `hasQualifier should delegate to underlying resolver`() { + val descriptor = mock(DependencyDescriptor::class.java) + `when`(delegate.hasQualifier(descriptor)).thenReturn(true) + resolver.hasQualifier(descriptor).shouldBeTrue() + verify(delegate).hasQualifier(descriptor) + } + + @Test + fun `hasQualifier should return false when delegate returns false`() { + val descriptor = mock(DependencyDescriptor::class.java) + `when`(delegate.hasQualifier(descriptor)).thenReturn(false) + resolver.hasQualifier(descriptor).shouldBeFalse() + } + + @Test + fun `cloneIfNecessary should delegate to underlying resolver`() { + val clonedResolver = mock(AutowireCandidateResolver::class.java) + `when`(delegate.cloneIfNecessary()).thenReturn(clonedResolver) + resolver.cloneIfNecessary() shouldBe clonedResolver + verify(delegate).cloneIfNecessary() + } + + @Test + fun `getLazyResolutionProxyClass should delegate to underlying resolver`() { + val descriptor = mock(DependencyDescriptor::class.java) + `when`(delegate.getLazyResolutionProxyClass(descriptor, "testBean")).thenReturn(null) + resolver.getLazyResolutionProxyClass(descriptor, "testBean").shouldBeNull() + verify(delegate).getLazyResolutionProxyClass(descriptor, "testBean") + } +} diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/CoverageBoostTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/CoverageBoostTest.kt new file mode 100644 index 000000000..e0aa6ca3b --- /dev/null +++ b/storm-kotlin/src/test/kotlin/st/orm/template/CoverageBoostTest.kt @@ -0,0 +1,789 @@ +package st.orm.template + +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.flow.count +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.jdbc.Sql +import org.springframework.test.context.junit.jupiter.SpringExtension +import st.orm.Data +import st.orm.Metamodel +import st.orm.Operator.* +import st.orm.Ref +import st.orm.repository.* +import st.orm.template.model.* +import javax.sql.DataSource + +/** + * Broad coverage tests targeting uncovered default interface method implementations + * for EntityRepository, ProjectionRepository, QueryTemplate, Query, and PreparedQuery. + */ +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [IntegrationConfig::class]) +@Sql("/data.sql") +open class CoverageBoostTest( + @Autowired val orm: ORMTemplate, + @Autowired val dataSource: DataSource, +) { + + @Suppress("UNCHECKED_CAST") + private fun metamodel(model: Model<*, *>, columnName: String): Metamodel = model.columns.first { it.name == columnName }.metamodel as Metamodel + + // ====================================================================== + // EntityRepository: Ref-cursor slice methods (all uncovered) + // ====================================================================== + + @Test + fun `entity sliceAfter with Ref cursor should return next page`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val cityRef = Ref.of(City::class.java, 2) + val slice = repo.sliceAfter(cityKey, cityRef, 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceAfterRef with Ref cursor should return next page refs`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val cityRef = Ref.of(City::class.java, 2) + val slice = repo.sliceAfterRef(cityKey, cityRef, 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceBefore with Ref cursor should return previous page`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val cityRef = Ref.of(City::class.java, 5) + val slice = repo.sliceBefore(cityKey, cityRef, 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceBeforeRef with Ref cursor should return previous page refs`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val cityRef = Ref.of(City::class.java, 5) + val slice = repo.sliceBeforeRef(cityKey, cityRef, 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceAfter with Ref cursor and WhereBuilder should filter`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfter(cityKey, cityRef, 10) { where(lastNamePath, LIKE, "%") } + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceAfter with Ref cursor and PredicateBuilder should filter`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfter(cityKey, cityRef, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceAfterRef with Ref cursor and WhereBuilder should filter refs`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfterRef(cityKey, cityRef, 10) { where(lastNamePath, LIKE, "%") } + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceAfterRef with Ref cursor and PredicateBuilder should filter refs`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfterRef(cityKey, cityRef, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceBefore with Ref cursor and WhereBuilder should filter`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBefore(cityKey, cityRef, 10) { where(lastNamePath, LIKE, "%") } + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceBefore with Ref cursor and PredicateBuilder should filter`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBefore(cityKey, cityRef, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceBeforeRef with Ref cursor and WhereBuilder should filter refs`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBeforeRef(cityKey, cityRef, 10) { where(lastNamePath, LIKE, "%") } + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceBeforeRef with Ref cursor and PredicateBuilder should filter refs`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBeforeRef(cityKey, cityRef, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + // Ref-cursor with sort metamodel + @Test + fun `entity sliceAfter with Ref cursor and sort should return sorted page`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val namePath = metamodel(repo.model, "first_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfter(cityKey, cityRef, namePath, "A", 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceAfterRef with Ref cursor and sort should return sorted refs`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val namePath = metamodel(repo.model, "first_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfterRef(cityKey, cityRef, namePath, "A", 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceBefore with Ref cursor and sort should return sorted page`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val namePath = metamodel(repo.model, "first_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBefore(cityKey, cityRef, namePath, "Z", 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `entity sliceBeforeRef with Ref cursor and sort should return sorted refs`() { + val repo = orm.entity(Owner::class) + val cityKey = metamodel(repo.model, "city_id").key() + val namePath = metamodel(repo.model, "first_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBeforeRef(cityKey, cityRef, namePath, "Z", 10) + slice.content.shouldNotBeEmpty() + } + + // sliceBeforeRef with WhereBuilder/PredicateBuilder (no cursor) + @Test + fun `entity sliceBeforeRef with WhereBuilder should filter refs descending`() { + val repo = orm.entity(City::class) + val idKey = metamodel(repo.model, "id").key() + val namePath = metamodel(repo.model, "name") + val slice = repo.sliceBeforeRef(idKey, 10) { where(namePath, LIKE, "M%") } + slice.content shouldHaveSize 3 + } + + @Test + fun `entity sliceBeforeRef with PredicateBuilder should filter refs descending`() { + val repo = orm.entity(City::class) + val idKey = metamodel(repo.model, "id").key() + val namePath = metamodel(repo.model, "name") + val slice = repo.sliceBeforeRef(idKey, 10, namePath like "M%") + slice.content shouldHaveSize 3 + } + + // findAllBy with Iterable (covers DefaultImpls for Iterable overload) + @Test + fun `entity findAllBy with iterable should return matching entities`() { + val repo = orm.entity(City::class) + val namePath = metamodel(repo.model, "name") + val cities = repo.findAllBy(namePath, listOf("Madison", "Windsor")) + cities shouldHaveSize 2 + } + + // ====================================================================== + // ProjectionRepository: Ref-cursor slice methods (all uncovered) + // ====================================================================== + + @Test + fun `projection sliceAfter with Ref cursor should return next page`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val cityRef = Ref.of(City::class.java, 2) + val slice = repo.sliceAfter(cityKey, cityRef, 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceAfterRef with Ref cursor should return next page refs`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val cityRef = Ref.of(City::class.java, 2) + val slice = repo.sliceAfterRef(cityKey, cityRef, 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceBefore with Ref cursor should return previous page`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val cityRef = Ref.of(City::class.java, 5) + val slice = repo.sliceBefore(cityKey, cityRef, 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceBeforeRef with Ref cursor should return previous page refs`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val cityRef = Ref.of(City::class.java, 5) + val slice = repo.sliceBeforeRef(cityKey, cityRef, 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceAfter with Ref cursor and WhereBuilder should filter`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfter(cityKey, cityRef, 10) { where(lastNamePath, LIKE, "%") } + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceAfter with Ref cursor and PredicateBuilder should filter`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfter(cityKey, cityRef, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceAfterRef with Ref cursor and WhereBuilder should filter refs`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfterRef(cityKey, cityRef, 10) { where(lastNamePath, LIKE, "%") } + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceAfterRef with Ref cursor and PredicateBuilder should filter refs`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfterRef(cityKey, cityRef, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceBefore with Ref cursor and WhereBuilder should filter`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBefore(cityKey, cityRef, 10) { where(lastNamePath, LIKE, "%") } + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceBefore with Ref cursor and PredicateBuilder should filter`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBefore(cityKey, cityRef, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceBeforeRef with Ref cursor and WhereBuilder should filter refs`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBeforeRef(cityKey, cityRef, 10) { where(lastNamePath, LIKE, "%") } + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceBeforeRef with Ref cursor and PredicateBuilder should filter refs`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBeforeRef(cityKey, cityRef, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + // Ref-cursor with sort metamodel for projection + @Test + fun `projection sliceAfter with Ref cursor and sort should return sorted page`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val namePath = metamodel(repo.model, "first_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfter(cityKey, cityRef, namePath, "A", 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceAfterRef with Ref cursor and sort should return sorted refs`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val namePath = metamodel(repo.model, "first_name") + val cityRef = Ref.of(City::class.java, 1) + val slice = repo.sliceAfterRef(cityKey, cityRef, namePath, "A", 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceBefore with Ref cursor and sort should return sorted page`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val namePath = metamodel(repo.model, "first_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBefore(cityKey, cityRef, namePath, "Z", 10) + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceBeforeRef with Ref cursor and sort should return sorted refs`() { + val repo = orm.projection(OwnerView::class) + val cityKey = metamodel(repo.model, "city_id").key() + val namePath = metamodel(repo.model, "first_name") + val cityRef = Ref.of(City::class.java, 6) + val slice = repo.sliceBeforeRef(cityKey, cityRef, namePath, "Z", 10) + slice.content.shouldNotBeEmpty() + } + + // ProjectionRepository uncovered: sliceBeforeRef with WhereBuilder/PredicateBuilder (no cursor) + @Test + fun `projection sliceBeforeRef with WhereBuilder should filter refs`() { + val repo = orm.projection(OwnerView::class) + val idKey = metamodel(repo.model, "id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val slice = repo.sliceBeforeRef(idKey, 10) { where(lastNamePath, EQUALS, "Davis") } + slice.content shouldHaveSize 2 + } + + @Test + fun `projection sliceBeforeRef with PredicateBuilder should filter refs`() { + val repo = orm.projection(OwnerView::class) + val idKey = metamodel(repo.model, "id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val slice = repo.sliceBeforeRef(idKey, 10, lastNamePath eq "Davis") + slice.content shouldHaveSize 2 + } + + // ProjectionRepository: select with template builder (uncovered in DefaultImpls) + @Test + fun `projection select with template builder should return typed results`() { + val repo = orm.projection(OwnerView::class) + val results = repo.select(OwnerView::class) { t(Templates.select(OwnerView::class)) }.resultList + results shouldHaveSize 10 + } + + // ProjectionRepository: findAllBy with Iterable (covers DefaultImpls) + @Test + fun `projection findAllBy with iterable should return matching projections`() { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + val projections = repo.findAllBy(lastNamePath, listOf("Davis", "Franklin")) + projections shouldHaveSize 3 + } + + // ProjectionRepository: uncovered convenience methods + @Test + fun `projection exists with WhereBuilder should return true for matching value`() { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + repo.exists { where(lastNamePath, EQUALS, "Davis") } shouldBe true + } + + @Test + fun `projection exists with PredicateBuilder should return true for matching value`() { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + repo.exists(lastNamePath eq "Davis") shouldBe true + } + + @Test + fun `projection findBy should return matching projection`() { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + val owner = repo.findBy(lastNamePath, "Franklin") + owner.shouldNotBeNull() + owner.lastName shouldBe "Franklin" + } + + @Test + fun `projection getBy should return matching projection`() { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + val owner = repo.getBy(lastNamePath, "Franklin") + owner.lastName shouldBe "Franklin" + } + + @Test + fun `projection findRefBy should return ref for matching value`() { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + val ref = repo.findRefBy(lastNamePath, "Franklin") + ref.shouldNotBeNull() + } + + @Test + fun `projection getRefBy should return ref for matching value`() { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + val ref = repo.getRefBy(lastNamePath, "Franklin") + ref.shouldNotBeNull() + } + + @Test + fun `projection findAllBy should return matching projections`() { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + val owners = repo.findAllBy(lastNamePath, "Davis") + owners shouldHaveSize 2 + } + + @Test + fun `projection selectBy should return matching projections flow`(): Unit = runBlocking { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + val owners = repo.selectBy(lastNamePath, "Davis").toList() + owners shouldHaveSize 2 + } + + @Test + fun `projection findAllRefBy with single value should return matching refs`() { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + val refs = repo.findAllRefBy(lastNamePath, "Davis") + refs shouldHaveSize 2 + } + + @Test + fun `projection selectRefBy should return matching refs flow`(): Unit = runBlocking { + val repo = orm.projection(OwnerView::class) + val lastNamePath = metamodel(repo.model, "last_name") + val refs = repo.selectRefBy(lastNamePath, "Davis").toList() + refs shouldHaveSize 2 + } + + // ProjectionRepository: sliceAfter/sliceBefore with PredicateBuilder (non-Ref cursor, uncovered variants) + @Test + fun `projection sliceAfter with cursor and PredicateBuilder should filter`() { + val repo = orm.projection(OwnerView::class) + val idKey = metamodel(repo.model, "id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val slice = repo.sliceAfter(idKey, 1, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceAfterRef with cursor and PredicateBuilder should filter refs`() { + val repo = orm.projection(OwnerView::class) + val idKey = metamodel(repo.model, "id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val slice = repo.sliceAfterRef(idKey, 1, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceBefore with cursor and PredicateBuilder should filter`() { + val repo = orm.projection(OwnerView::class) + val idKey = metamodel(repo.model, "id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val slice = repo.sliceBefore(idKey, 10, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + @Test + fun `projection sliceBeforeRef with cursor and PredicateBuilder should filter refs`() { + val repo = orm.projection(OwnerView::class) + val idKey = metamodel(repo.model, "id").key() + val lastNamePath = metamodel(repo.model, "last_name") + val slice = repo.sliceBeforeRef(idKey, 10, 10, lastNamePath like "%") + slice.content.shouldNotBeEmpty() + } + + // ====================================================================== + // ORMTemplate.DefaultImpls: subquery, selectFrom, model, query + // ====================================================================== + + @Test + fun `subquery with single type should return query builder`() { + val builder = orm.subquery(City::class) + builder.shouldNotBeNull() + } + + @Test + fun `subquery with two types should return query builder`() { + val builder = orm.subquery(City::class, City::class) + builder.shouldNotBeNull() + } + + @Test + fun `subquery with template builder should return query builder`() { + val builder = orm.subquery(City::class) { t(Templates.select(City::class)) } + builder.shouldNotBeNull() + } + + @Test + fun `selectFrom with single type should return query builder`() { + val results = orm.selectFrom(City::class).resultList + results shouldHaveSize 6 + } + + @Test + fun `selectFrom with two types should return query builder`() { + val results = orm.selectFrom(City::class, City::class).resultList + results shouldHaveSize 6 + } + + @Test + fun `selectFrom with template builder should return query builder`() { + val results = orm.selectFrom(City::class, City::class) { t(Templates.select(City::class)) }.resultList + results shouldHaveSize 6 + } + + @Test + fun `model with single type should return model info`() { + val model = orm.model(City::class) + model.shouldNotBeNull() + model.name shouldBe "city" + } + + @Test + fun `query with template builder should execute query`() { + val result = orm.query { "SELECT COUNT(*) FROM city" }.singleResult + result shouldBe arrayOf(6L) + } + + // ====================================================================== + // Query.DefaultImpls: typed result methods and PreparedQuery + // ====================================================================== + + @Test + fun `query getSingleResult with type should return typed result`() { + val city = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)} WHERE id = ${t(1)}" } + .getSingleResult(City::class) + city.name shouldBe "Sun Paririe" + } + + @Test + fun `query getOptionalResult with type should return typed result`() { + val city = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)} WHERE id = ${t(1)}" } + .getOptionalResult(City::class) + city.shouldNotBeNull() + city.name shouldBe "Sun Paririe" + } + + @Test + fun `query getOptionalResult with type should return null for no match`() { + val city = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)} WHERE id = ${t(9999)}" } + .getOptionalResult(City::class) + city.shouldBeNull() + } + + @Test + fun `query getResultList with type should return typed list`() { + val cities = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)}" } + .getResultList(City::class) + cities shouldHaveSize 6 + } + + @Test + fun `query getResultFlow with type should return typed flow`(): Unit = runBlocking { + val count = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)}" } + .getResultFlow(City::class) + .count() + count shouldBe 6 + } + + @Test + fun `query resultFlow should return flow of arrays`(): Unit = runBlocking { + val count = orm.query("SELECT id, name FROM city") + .resultFlow + .count() + count shouldBe 6 + } + + @Test + fun `query getRefList should return ref list`() { + val refs = orm.query("SELECT id FROM city") + .getRefList(City::class, Int::class) + refs shouldHaveSize 6 + } + + @Test + fun `query getRefFlow should return ref flow`(): Unit = runBlocking { + val count = orm.query("SELECT id FROM city") + .getRefFlow(City::class, Int::class) + .count() + count shouldBe 6 + } + + // PreparedQuery default methods + @Test + fun `prepared query getSingleResult with type should return typed result`() { + orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)} WHERE id = ${t(1)}" }.prepare().use { preparedQuery -> + val city = preparedQuery.getSingleResult(City::class) + city.name shouldBe "Sun Paririe" + } + } + + @Test + fun `prepared query getOptionalResult with type should return typed result`() { + orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)} WHERE id = ${t(1)}" }.prepare().use { preparedQuery -> + val city = preparedQuery.getOptionalResult(City::class) + city.shouldNotBeNull() + } + } + + @Test + fun `prepared query getResultList with type should return typed list`() { + orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)}" }.prepare().use { preparedQuery -> + val cities = preparedQuery.getResultList(City::class) + cities shouldHaveSize 6 + } + } + + @Test + fun `prepared query getResultFlow with type should return typed flow`(): Unit = runBlocking { + orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)}" }.prepare().use { preparedQuery -> + val count = preparedQuery.getResultFlow(City::class).count() + count shouldBe 6 + } + } + + @Test + fun `prepared query singleResult should return array`() { + orm.query("SELECT COUNT(*) FROM city").prepare().use { preparedQuery -> + val result = preparedQuery.singleResult + result shouldBe arrayOf(6L) + } + } + + @Test + fun `prepared query optionalResult should return array`() { + orm.query("SELECT COUNT(*) FROM city").prepare().use { preparedQuery -> + val result = preparedQuery.optionalResult + result.shouldNotBeNull() + } + } + + @Test + fun `prepared query resultList should return list of arrays`() { + orm.query("SELECT id, name FROM city").prepare().use { preparedQuery -> + val results = preparedQuery.resultList + results shouldHaveSize 6 + } + } + + @Test + fun `prepared query resultFlow should return flow of arrays`(): Unit = runBlocking { + orm.query("SELECT id, name FROM city").prepare().use { preparedQuery -> + val count = preparedQuery.resultFlow.count() + count shouldBe 6 + } + } + + @Test + fun `prepared query resultCount should return count`() { + orm.query("SELECT id FROM city").prepare().use { preparedQuery -> + val count = preparedQuery.resultCount + count shouldBe 6L + } + } + + @Test + fun `prepared query getRefList should return ref list`() { + orm.query("SELECT id FROM city").prepare().use { preparedQuery -> + val refs = preparedQuery.getRefList(City::class, Int::class) + refs shouldHaveSize 6 + } + } + + @Test + fun `prepared query getRefFlow should return ref flow`(): Unit = runBlocking { + orm.query("SELECT id FROM city").prepare().use { preparedQuery -> + val count = preparedQuery.getRefFlow(City::class, Int::class).count() + count shouldBe 6 + } + } + + // ====================================================================== + // PredicateBuilderFactory: createRef / createRefWithId + // ====================================================================== + + @Test + fun `createRef predicate builder should return PredicateBuilder`() { + val metamodel = Metamodel.of(Pet::class.java, "owner") + val refs = listOf(Ref.of(Owner::class.java, 1), Ref.of(Owner::class.java, 2)) + val predicate = st.orm.template.impl.createRef( + metamodel, + st.orm.Operator.IN, + refs, + ) + predicate.shouldNotBeNull() + predicate.shouldBeInstanceOf>() + } + + @Test + fun `createRefWithId predicate builder should return PredicateBuilder`() { + val metamodel = Metamodel.of(Pet::class.java, "owner") + val refs = listOf(Ref.of(Owner::class.java, 1), Ref.of(Owner::class.java, 2)) + val predicate = st.orm.template.impl.createRefWithId( + metamodel, + st.orm.Operator.IN, + refs, + ) + predicate.shouldNotBeNull() + predicate.shouldBeInstanceOf>() + } + + // ====================================================================== + // QueryTemplate.DefaultImpls: query with TemplateBuilder + // ====================================================================== + + @Test + fun `query with TemplateBuilder should return Query`() { + val result = orm.query { "SELECT COUNT(*) FROM city" }.singleResult + result shouldBe arrayOf(6L) + } +} diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/TemplatesTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/TemplatesTest.kt index 2e89f4606..2ca5f60b9 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/TemplatesTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/TemplatesTest.kt @@ -563,6 +563,62 @@ open class TemplatesTest( } } + // ====================================================================== + // Factory: ORMTemplate.of(DataSource, StormConfig, decorator) + // ====================================================================== + + @Test + fun `ORMTemplate of DataSource with StormConfig and decorator should create valid template`() { + val config = StormConfig.defaults() + val template = ORMTemplate.of(dataSource, config) { it } + template.shouldNotBe(null) + template.shouldBeInstanceOf() + template.entity(City::class).findAll().size shouldBe 6 + } + + // ====================================================================== + // Factory: ORMTemplate.of(Connection, StormConfig, decorator) + // ====================================================================== + + @Test + fun `ORMTemplate of Connection with StormConfig and decorator should create valid template`() { + dataSource.connection.use { conn -> + val config = StormConfig.defaults() + val template = ORMTemplate.of(conn, config) { it } + template.shouldNotBe(null) + template.shouldBeInstanceOf() + template.entity(City::class).findAll().size shouldBe 6 + } + } + + // ====================================================================== + // Extension: DataSource.orm(StormConfig, decorator) + // ====================================================================== + + @Test + fun `DataSource orm extension with StormConfig and decorator should create valid template`() { + val config = StormConfig.defaults() + val template = dataSource.orm(config) { it } + template.shouldNotBe(null) + template.shouldBeInstanceOf() + template.entity(City::class).findAll().size shouldBe 6 + } + + // ====================================================================== + // Extension: Connection.orm(StormConfig, decorator) + // ====================================================================== + + @Test + fun `Connection orm extension with StormConfig and decorator should create valid template`() { + dataSource.connection.use { conn -> + val config = StormConfig.defaults() + val template = conn.orm(config) { it } + template.shouldNotBe(null) + template.shouldBeInstanceOf() + template.entity(City::class).findAll().size shouldBe 6 + } + } + // ====================================================================== // ORMTemplate.withEntityCallback // ====================================================================== diff --git a/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBEntityRepositoryTest.java b/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBEntityRepositoryTest.java index 0bebc7eec..0ea8e8d7c 100644 --- a/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBEntityRepositoryTest.java +++ b/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBEntityRepositoryTest.java @@ -1931,4 +1931,51 @@ INSERT INTO pet (name, birth_date, type_id, owner_id) } }); } + + @Test + public void testUpsertAndFetchWithSequenceExisting() { + var repo = PreparedStatementTemplate.ORM(dataSource).entity(Pet.class); + // First insert a pet and get the id. + var inserted = repo.insertAndFetch(Pet.builder() + .name("Buddy") + .birthDate(LocalDate.of(2020, 1, 1)) + .type(PetType.builder().id(1).build()) + .owner(Owner.builder().id(1).build()) + .build()); + assertNotNull(inserted.id()); + // Now upsert the same pet with an existing non-default id. + var updated = repo.upsertAndFetch(inserted.toBuilder().name("Max").build()); + assertEquals(inserted.id(), updated.id()); + assertEquals("Max", updated.name()); + } + + @Test + public void testUpsertAndFetchWithSequenceExistingBatch() { + var repo = PreparedStatementTemplate.ORM(dataSource).entity(Pet.class); + // First insert two pets and get the ids. + var insertedIds = repo.insertAndFetchIds(List.of( + Pet.builder() + .name("Buddy") + .birthDate(LocalDate.of(2020, 1, 1)) + .type(PetType.builder().id(1).build()) + .owner(Owner.builder().id(1).build()) + .build(), + Pet.builder() + .name("Rex") + .birthDate(LocalDate.of(2020, 2, 1)) + .type(PetType.builder().id(1).build()) + .owner(Owner.builder().id(1).build()) + .build())); + assertEquals(2, insertedIds.size()); + // Now upsert the same pets with existing non-default ids. + var updatedEntities = repo.upsertAndFetch(List.of( + Pet.builder().id(insertedIds.get(0)).name("Max").birthDate(LocalDate.of(2020, 1, 1)) + .type(PetType.builder().id(1).build()).owner(Owner.builder().id(1).build()).build(), + Pet.builder().id(insertedIds.get(1)).name("Bella").birthDate(LocalDate.of(2020, 2, 1)) + .type(PetType.builder().id(1).build()).owner(Owner.builder().id(1).build()).build() + )).stream().sorted(Comparator.comparingInt(Entity::id)).toList(); + assertEquals(2, updatedEntities.size()); + assertEquals("Max", updatedEntities.get(0).name()); + assertEquals("Bella", updatedEntities.get(1).name()); + } } From 643b46fe203ba026ee969c5c8ab4f053ba305e02 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Tue, 3 Mar 2026 09:09:53 +0100 Subject: [PATCH 3/3] Add test cases. Remove warnings. Relates to #60 --- .../core/template/SchemaValidatorTest.java | 33 +++++++++++++++++++ .../st/orm/template/ConnectionProviderTest.kt | 19 +++++------ .../orm/template/ORMTemplateExtendedTest.kt | 2 +- .../JsonORMConverterAdditionalTest.kt | 1 + .../RefSerializerEdgeCaseTest.kt | 2 ++ 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java b/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java index fa373e50f..036de2756 100644 --- a/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java +++ b/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java @@ -34,6 +34,7 @@ import javax.sql.DataSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import st.orm.Data; import st.orm.DbIgnore; import st.orm.DbTable; import st.orm.Entity; @@ -599,6 +600,38 @@ void testForeignKeyIgnoredByDbIgnore() throws SQLException { "Expected no FOREIGN_KEY_MISSING when @DbIgnore suppresses it, but got: " + errors); } + // --- Polymorphic FK validation tests --- + + // Polymorphic FK: sealed Data interface with independent entity subtypes in separate tables. + sealed interface Commentable extends Data permits CommentablePost, CommentablePhoto {} + record CommentablePost(@PK Integer id, @Nonnull String title) implements Commentable, Entity {} + record CommentablePhoto(@PK Integer id, @Nonnull String url) implements Commentable, Entity {} + + public record EntityWithPolymorphicFk( + @PK Integer id, + @Nonnull String name, + @Nullable @FK Ref commentable + ) implements Entity {} + + @Test + void testPolymorphicFkSkipsValidation() throws SQLException { + execute("CREATE TABLE commentable_post (id INTEGER AUTO_INCREMENT, title VARCHAR(255) NOT NULL, PRIMARY KEY (id))"); + execute("CREATE TABLE commentable_photo (id INTEGER AUTO_INCREMENT, url VARCHAR(255) NOT NULL, PRIMARY KEY (id))"); + execute("CREATE TABLE entity_with_polymorphic_fk (" + + "id INTEGER AUTO_INCREMENT, " + + "name VARCHAR(255) NOT NULL, " + + "commentable_type VARCHAR(255), " + + "commentable_id INTEGER, " + + "PRIMARY KEY (id))"); + + List errors = SchemaValidator.of(dataSource) + .validate(List.of(EntityWithPolymorphicFk.class)); + + // Polymorphic FK should skip FK constraint validation (no FOREIGN_KEY_MISSING error). + assertFalse(errors.stream().anyMatch(error -> error.kind() == ErrorKind.FOREIGN_KEY_MISSING), + "Expected no FOREIGN_KEY_MISSING for polymorphic FK, but got: " + errors); + } + // --- Helpers --- private void execute(String sql) throws SQLException { diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/ConnectionProviderTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/ConnectionProviderTest.kt index e9019043e..5603dae96 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/ConnectionProviderTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/ConnectionProviderTest.kt @@ -109,18 +109,17 @@ open class ConnectionProviderTest( val connection = dataSource.connection try { CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.beforeAccess(connection) - val exception = assertThrows { - val thread = Thread { - CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.beforeAccess(connection) - } - thread.start() - thread.join() - // If the thread threw, we need to check it + var caughtException: Throwable? = null + val thread = Thread { + CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.beforeAccess(connection) + } + thread.setUncaughtExceptionHandler { _, throwable -> caughtException = throwable } + thread.start() + thread.join() + assertThrows { + caughtException?.let { throw it } } - // The exception is thrown in the other thread, so we catch it differently CoroutineAwareConnectionProviderImpl.ConcurrencyDetector.afterAccess(connection) - } catch (ignored: Throwable) { - // Expected: concurrent access throws } finally { connection.close() } diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateExtendedTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateExtendedTest.kt index 1a874b184..77c05a156 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateExtendedTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateExtendedTest.kt @@ -1242,7 +1242,7 @@ open class ORMTemplateExtendedTest( @Test fun `query getResultStream with KClass should return stream`() { val query = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)}" } - val count = query.getResultStream(City::class).count() + val count = query.getResultStream(City::class).use { it.count() } count shouldBe 6L } diff --git a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterAdditionalTest.kt b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterAdditionalTest.kt index a3ad160d2..d9b7c0fc3 100644 --- a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterAdditionalTest.kt +++ b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterAdditionalTest.kt @@ -133,6 +133,7 @@ open class JsonORMConverterAdditionalTest( // -- Sealed class polymorphic deserialization -- + @OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) @JsonClassDiscriminator("@type") @Serializable sealed interface Shape diff --git a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/RefSerializerEdgeCaseTest.kt b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/RefSerializerEdgeCaseTest.kt index 86b70cb4c..2bc3d81ab 100644 --- a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/RefSerializerEdgeCaseTest.kt +++ b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/RefSerializerEdgeCaseTest.kt @@ -351,6 +351,7 @@ class RefSerializerEdgeCaseTest { /** * A minimal encoder that is NOT a JsonEncoder, used to test RefSerializer's non-JSON error path. */ +@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) private class NonJsonEncoder : kotlinx.serialization.encoding.AbstractEncoder() { var result: String = "" @@ -368,6 +369,7 @@ private class NonJsonEncoder : kotlinx.serialization.encoding.AbstractEncoder() /** * A minimal decoder that is NOT a JsonDecoder, used to test RefSerializer's non-JSON error path. */ +@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) private class NonJsonDecoder : kotlinx.serialization.encoding.AbstractDecoder() { override val serializersModule: SerializersModule = SerializersModule {}