Expressions are only allowed to appear in a template when they are part of a {@code WHERE} clause. When the
+ *
Expressions can appear in any clause that supports them, such as {@code WHERE} and {@code HAVING}. When the
* shared compilation logic encounters an expression, it is propagated as a {@code Cacheable} element.
*
*
The wrapped expression is not rendered directly into SQL. It is included as part of the overall compilation key so
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/CacheableProcessor.java b/storm-core/src/main/java/st/orm/core/template/impl/CacheableProcessor.java
index ee6b8d937..852bc5204 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/CacheableProcessor.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/CacheableProcessor.java
@@ -23,6 +23,7 @@
import st.orm.Ref;
import st.orm.core.template.SqlTemplateException;
import st.orm.core.template.TemplateString;
+import st.orm.core.template.impl.BindHint.NoBindHint;
import st.orm.core.template.impl.Elements.ObjectExpression;
import st.orm.core.template.impl.Elements.TemplateExpression;
@@ -122,13 +123,16 @@ private static Class> getTypeShape(@Nonnull Object object) throws SqlTemplateE
*
This method is responsible for producing the compile-time representation of the element. It must not perform
* runtime binding. Any binding should be deferred to {@link #bind(Cacheable, TemplateBinder, BindHint)}.
*
- * @param object the element to compile.
+ * @param cacheable the element to compile.
* @param compiler the active compiler context.
* @return the compiled result for this element.
*/
@Override
- public CompiledElement compile(@Nonnull Cacheable object, @Nonnull TemplateCompiler compiler) {
- throw new UncheckedSqlTemplateException(new SqlTemplateException("Compilation not supported for expressions."));
+ public CompiledElement compile(@Nonnull Cacheable cacheable, @Nonnull TemplateCompiler compiler)
+ throws SqlTemplateException {
+ return new CompiledElement(
+ compiler.getQueryModel().compileExpression(cacheable.expression(), compiler),
+ NoBindHint.INSTANCE);
}
/**
@@ -138,12 +142,12 @@ public CompiledElement compile(@Nonnull Cacheable object, @Nonnull TemplateCompi
* parameters, registering bind variables, or applying runtime-only adjustments that must not affect the compiled
* SQL shape.
*
- * @param object the element that was compiled.
+ * @param cacheable the element that was compiled.
* @param binder the binder used to bind runtime values.
* @param bindHint the bind hint for the element, providing additional context for binding.
*/
@Override
- public void bind(@Nonnull Cacheable object, @Nonnull TemplateBinder binder, @Nonnull BindHint bindHint) {
- throw new UncheckedSqlTemplateException(new SqlTemplateException("Binding not supported for expressions."));
+ public void bind(@Nonnull Cacheable cacheable, @Nonnull TemplateBinder binder, @Nonnull BindHint bindHint) {
+ binder.getQueryModel().bindExpression(cacheable.expression(), binder);
}
}
diff --git a/storm-core/src/test/java/st/orm/core/repository/impl/DefaultORMReflectionImplTest.java b/storm-core/src/test/java/st/orm/core/repository/impl/DefaultORMReflectionImplTest.java
new file mode 100644
index 000000000..f467351d4
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/repository/impl/DefaultORMReflectionImplTest.java
@@ -0,0 +1,196 @@
+package st.orm.core.repository.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 java.util.List;
+import org.junit.jupiter.api.Test;
+import st.orm.Data;
+import st.orm.Entity;
+import st.orm.PK;
+import st.orm.PersistenceException;
+
+/**
+ * Tests for {@link DefaultORMReflectionImpl}.
+ */
+public class DefaultORMReflectionImplTest {
+
+ private final DefaultORMReflectionImpl reflection = new DefaultORMReflectionImpl();
+
+ record SimpleEntity(@PK Integer id, String name) implements Entity {}
+ record AllDefaults(int value, String text) implements Data {}
+
+ sealed interface SealedData extends Data permits SubData1, SubData2 {}
+ record SubData1(int id) implements SealedData {}
+ record SubData2(int id) implements SealedData {}
+
+ // -- getId --
+
+ @Test
+ public void testGetId() {
+ var entity = new SimpleEntity(42, "test");
+ Object id = reflection.getId(entity);
+ assertEquals(42, id);
+ }
+
+ // -- getRecordValue --
+
+ @Test
+ public void testGetRecordValue() {
+ var entity = new SimpleEntity(1, "hello");
+ assertEquals(1, reflection.getRecordValue(entity, 0));
+ assertEquals("hello", reflection.getRecordValue(entity, 1));
+ }
+
+ // -- findRecordType --
+
+ @Test
+ public void testFindRecordType() {
+ var result = reflection.findRecordType(SimpleEntity.class);
+ assertTrue(result.isPresent());
+ assertEquals(2, result.get().fields().size());
+ }
+
+ @Test
+ public void testFindRecordTypeNonRecord() {
+ var result = reflection.findRecordType(String.class);
+ assertFalse(result.isPresent());
+ }
+
+ // -- getType --
+
+ @Test
+ public void testGetType() {
+ assertEquals(SimpleEntity.class, reflection.getType(SimpleEntity.class));
+ }
+
+ @Test
+ public void testGetTypeNotClass() {
+ assertThrows(PersistenceException.class, () -> reflection.getType("not a class"));
+ }
+
+ @Test
+ public void testGetTypeNotData() {
+ assertThrows(PersistenceException.class, () -> reflection.getType(String.class));
+ }
+
+ // -- getDataType --
+
+ @Test
+ public void testGetDataType() {
+ assertEquals(SimpleEntity.class, reflection.getDataType(SimpleEntity.class));
+ }
+
+ @Test
+ public void testGetDataTypeNotClass() {
+ assertThrows(PersistenceException.class, () -> reflection.getDataType("not a class"));
+ }
+
+ @Test
+ public void testGetDataTypeNotData() {
+ assertThrows(PersistenceException.class, () -> reflection.getDataType(String.class));
+ }
+
+ // -- isDefaultValue --
+
+ @Test
+ public void testIsDefaultValueNull() {
+ assertTrue(reflection.isDefaultValue(null));
+ }
+
+ @Test
+ public void testIsDefaultValuePrimitiveDefaults() {
+ assertTrue(reflection.isDefaultValue(0));
+ assertTrue(reflection.isDefaultValue(0L));
+ assertTrue(reflection.isDefaultValue(0.0f));
+ assertTrue(reflection.isDefaultValue(0.0));
+ assertTrue(reflection.isDefaultValue((short) 0));
+ assertTrue(reflection.isDefaultValue((byte) 0));
+ assertTrue(reflection.isDefaultValue('\u0000'));
+ assertTrue(reflection.isDefaultValue(false));
+ }
+
+ @Test
+ public void testIsDefaultValuePrimitiveNonDefaults() {
+ assertFalse(reflection.isDefaultValue(42));
+ assertFalse(reflection.isDefaultValue(1L));
+ assertFalse(reflection.isDefaultValue(1.0f));
+ assertFalse(reflection.isDefaultValue(1.0));
+ assertFalse(reflection.isDefaultValue((short) 1));
+ assertFalse(reflection.isDefaultValue((byte) 1));
+ assertFalse(reflection.isDefaultValue('A'));
+ assertFalse(reflection.isDefaultValue(true));
+ }
+
+ @Test
+ public void testIsDefaultValueRecordWithDefaults() {
+ assertTrue(reflection.isDefaultValue(new AllDefaults(0, null)));
+ }
+
+ @Test
+ public void testIsDefaultValueRecordWithNonDefaults() {
+ assertFalse(reflection.isDefaultValue(new AllDefaults(1, null)));
+ assertFalse(reflection.isDefaultValue(new AllDefaults(0, "value")));
+ }
+
+ @Test
+ public void testIsDefaultValueString() {
+ assertFalse(reflection.isDefaultValue("hello"));
+ }
+
+ // -- isSupportedType --
+
+ @Test
+ public void testIsSupportedType() {
+ assertTrue(reflection.isSupportedType(SimpleEntity.class));
+ }
+
+ @Test
+ public void testIsSupportedTypeNotClass() {
+ assertFalse(reflection.isSupportedType("not a class"));
+ }
+
+ // -- getPermittedSubclasses --
+
+ @Test
+ public void testGetPermittedSubclasses() {
+ List> subclasses = reflection.getPermittedSubclasses(SealedData.class);
+ assertEquals(2, subclasses.size());
+ }
+
+ @Test
+ public void testGetPermittedSubclassesNonSealed() {
+ List> subclasses = reflection.getPermittedSubclasses(SimpleEntity.class);
+ assertTrue(subclasses.isEmpty());
+ }
+
+ // -- isDefaultMethod --
+
+ @Test
+ public void testIsDefaultMethod() throws NoSuchMethodException {
+ var method = Object.class.getMethod("toString");
+ assertFalse(reflection.isDefaultMethod(method));
+ }
+
+ // -- invoke --
+
+ @Test
+ public void testInvoke() {
+ var entity = new SimpleEntity(5, "test");
+ var recordType = reflection.findRecordType(SimpleEntity.class).orElseThrow();
+ var idField = recordType.fields().getFirst();
+ assertEquals(5, reflection.invoke(idField, entity));
+ }
+
+ // -- execute (default method) --
+
+ @Test
+ public void testExecuteObjectMethod() throws Throwable {
+ var entity = new SimpleEntity(1, "test");
+ var method = Object.class.getMethod("toString");
+ assertNotNull(reflection.execute(entity, method));
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/spi/DefaultSqlDialectTest.java b/storm-core/src/test/java/st/orm/core/spi/DefaultSqlDialectTest.java
new file mode 100644
index 000000000..e728a2679
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/spi/DefaultSqlDialectTest.java
@@ -0,0 +1,190 @@
+package st.orm.core.spi;
+
+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 java.util.LinkedHashMap;
+import java.util.List;
+import java.util.SequencedMap;
+import org.junit.jupiter.api.Test;
+import st.orm.PersistenceException;
+import st.orm.StormConfig;
+import st.orm.core.template.SqlTemplateException;
+
+/**
+ * Tests for {@link DefaultSqlDialect}.
+ */
+public class DefaultSqlDialectTest {
+
+ @Test
+ public void testNameNoAnsiEscaping() {
+ var config = StormConfig.of(java.util.Map.of("storm.ansi_escaping", "false"));
+ var dialect = new DefaultSqlDialect(config);
+ assertEquals("Default", dialect.name());
+ }
+
+ @Test
+ public void testNameAnsiEscaping() {
+ var config = StormConfig.of(java.util.Map.of("storm.ansi_escaping", "true"));
+ var dialect = new DefaultSqlDialect(config);
+ assertEquals("Default[ansi]", dialect.name());
+ }
+
+ @Test
+ public void testSupportsDeleteAlias() {
+ var dialect = new DefaultSqlDialect();
+ assertFalse(dialect.supportsDeleteAlias());
+ }
+
+ @Test
+ public void testSupportsMultiValueTuples() {
+ var dialect = new DefaultSqlDialect();
+ assertFalse(dialect.supportsMultiValueTuples());
+ }
+
+ @Test
+ public void testIsKeyword() {
+ var dialect = new DefaultSqlDialect();
+ assertTrue(dialect.isKeyword("SELECT"));
+ assertTrue(dialect.isKeyword("select"));
+ assertFalse(dialect.isKeyword("mycolumn"));
+ }
+
+ @Test
+ public void testEscapeNoAnsi() {
+ var config = StormConfig.of(java.util.Map.of("storm.ansi_escaping", "false"));
+ var dialect = new DefaultSqlDialect(config);
+ assertEquals("myTable", dialect.escape("myTable"));
+ }
+
+ @Test
+ public void testEscapeAnsi() {
+ var config = StormConfig.of(java.util.Map.of("storm.ansi_escaping", "true"));
+ var dialect = new DefaultSqlDialect(config);
+ assertEquals("\"myTable\"", dialect.escape("myTable"));
+ }
+
+ @Test
+ public void testEscapeAnsiWithQuotes() {
+ var config = StormConfig.of(java.util.Map.of("storm.ansi_escaping", "true"));
+ var dialect = new DefaultSqlDialect(config);
+ assertEquals("\"my\"\"Table\"", dialect.escape("my\"Table"));
+ }
+
+ @Test
+ public void testGetSafeIdentifierRegular() {
+ var dialect = new DefaultSqlDialect();
+ assertEquals("myColumn", dialect.getSafeIdentifier("myColumn"));
+ }
+
+ @Test
+ public void testGetSafeIdentifierKeyword() {
+ var dialect = new DefaultSqlDialect();
+ // "SELECT" is a keyword, so it gets escaped.
+ String result = dialect.getSafeIdentifier("SELECT");
+ assertNotNull(result);
+ }
+
+ @Test
+ public void testGetValidIdentifierPattern() {
+ var dialect = new DefaultSqlDialect();
+ var pattern = dialect.getValidIdentifierPattern();
+ assertTrue(pattern.matcher("myColumn").matches());
+ assertFalse(pattern.matcher("123abc").matches());
+ }
+
+ @Test
+ public void testSingleLineCommentPattern() {
+ var dialect = new DefaultSqlDialect();
+ assertNotNull(dialect.getSingleLineCommentPattern());
+ }
+
+ @Test
+ public void testMultiLineCommentPattern() {
+ var dialect = new DefaultSqlDialect();
+ assertNotNull(dialect.getMultiLineCommentPattern());
+ }
+
+ @Test
+ public void testIdentifierPattern() {
+ var dialect = new DefaultSqlDialect();
+ assertNotNull(dialect.getIdentifierPattern());
+ }
+
+ @Test
+ public void testQuoteLiteralPattern() {
+ var dialect = new DefaultSqlDialect();
+ assertNotNull(dialect.getQuoteLiteralPattern());
+ }
+
+ @Test
+ public void testLimitOnly() {
+ var dialect = new DefaultSqlDialect();
+ assertEquals("LIMIT 10", dialect.limit(10));
+ }
+
+ @Test
+ public void testOffsetOnly() {
+ var dialect = new DefaultSqlDialect();
+ assertEquals("OFFSET 5", dialect.offset(5));
+ }
+
+ @Test
+ public void testLimitAndOffset() {
+ var dialect = new DefaultSqlDialect();
+ assertEquals("LIMIT 10 OFFSET 5", dialect.limit(5, 10));
+ }
+
+ @Test
+ public void testApplyLimitAfterSelect() {
+ var dialect = new DefaultSqlDialect();
+ assertFalse(dialect.applyLimitAfterSelect());
+ }
+
+ @Test
+ public void testApplyLockHintAfterFrom() {
+ var dialect = new DefaultSqlDialect();
+ assertFalse(dialect.applyLockHintAfterFrom());
+ }
+
+ @Test
+ public void testForShareLockHint() {
+ var dialect = new DefaultSqlDialect();
+ assertEquals("FOR SHARE", dialect.forShareLockHint());
+ }
+
+ @Test
+ public void testForUpdateLockHint() {
+ var dialect = new DefaultSqlDialect();
+ assertEquals("FOR UPDATE", dialect.forUpdateLockHint());
+ }
+
+ @Test
+ public void testSequenceNextValThrows() {
+ var dialect = new DefaultSqlDialect();
+ assertThrows(PersistenceException.class, () -> dialect.sequenceNextVal("my_seq"));
+ }
+
+ @Test
+ public void testMultiValueInSingleRow() throws SqlTemplateException {
+ var dialect = new DefaultSqlDialect();
+ SequencedMap row = new LinkedHashMap<>();
+ row.put("id", 1);
+ String result = dialect.multiValueIn(List.of(row), v -> "?");
+ assertTrue(result.contains("id"));
+ }
+
+ @Test
+ public void testMultiValueInMultipleRows() throws SqlTemplateException {
+ var dialect = new DefaultSqlDialect();
+ SequencedMap row1 = new LinkedHashMap<>();
+ row1.put("id", 1);
+ SequencedMap row2 = new LinkedHashMap<>();
+ row2.put("id", 2);
+ String result = dialect.multiValueIn(List.of(row1, row2), v -> "?");
+ assertTrue(result.contains("OR"));
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/spi/EntityCacheImplTest.java b/storm-core/src/test/java/st/orm/core/spi/EntityCacheImplTest.java
new file mode 100644
index 000000000..cf5400281
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/spi/EntityCacheImplTest.java
@@ -0,0 +1,181 @@
+package st.orm.core.spi;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import st.orm.Entity;
+import st.orm.PK;
+
+/**
+ * Tests for {@link EntityCacheImpl}.
+ */
+public class EntityCacheImplTest {
+
+ record TestEntity(@PK Integer id, String name) implements Entity {}
+
+ @BeforeEach
+ public void resetMetrics() {
+ EntityCacheMetrics.getInstance().reset();
+ }
+
+ @Test
+ public void testInternReturnsEntityWhenCacheEmpty() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ TestEntity entity = new TestEntity(1, "Alice");
+ TestEntity interned = cache.intern(entity);
+ assertSame(entity, interned, "Should return the same entity when cache is empty");
+ }
+
+ @Test
+ public void testInternReturnsCachedInstanceForEqualEntity() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ TestEntity entity1 = new TestEntity(1, "Alice");
+ TestEntity entity2 = new TestEntity(1, "Alice");
+
+ TestEntity interned1 = cache.intern(entity1);
+ TestEntity interned2 = cache.intern(entity2);
+ assertSame(interned1, interned2, "Should return cached instance for equal entity");
+ }
+
+ @Test
+ public void testInternReplacesWhenEntityDiffers() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ TestEntity entity1 = new TestEntity(1, "Alice");
+ TestEntity entity2 = new TestEntity(1, "Updated Alice");
+
+ cache.intern(entity1);
+ TestEntity interned2 = cache.intern(entity2);
+ assertSame(entity2, interned2, "Should replace cache entry when entities differ");
+ }
+
+ @Test
+ public void testGetReturnsEntityAfterIntern() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ TestEntity entity = new TestEntity(1, "Alice");
+ cache.intern(entity);
+
+ Optional result = cache.get(1);
+ assertTrue(result.isPresent());
+ assertSame(entity, result.get());
+ }
+
+ @Test
+ public void testGetReturnsEmptyForMissingKey() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ Optional result = cache.get(999);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testRemoveEntry() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ TestEntity entity = new TestEntity(1, "Alice");
+ cache.intern(entity);
+ cache.remove(1);
+
+ Optional result = cache.get(1);
+ assertTrue(result.isEmpty(), "Should be empty after removal");
+ }
+
+ @Test
+ public void testClearRemovesAllEntries() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ cache.intern(new TestEntity(1, "Alice"));
+ cache.intern(new TestEntity(2, "Bob"));
+ cache.intern(new TestEntity(3, "Charlie"));
+
+ cache.clear();
+ assertTrue(cache.get(1).isEmpty());
+ assertTrue(cache.get(2).isEmpty());
+ assertTrue(cache.get(3).isEmpty());
+ }
+
+ @Test
+ public void testMultipleEntities() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ TestEntity alice = new TestEntity(1, "Alice");
+ TestEntity bob = new TestEntity(2, "Bob");
+
+ cache.intern(alice);
+ cache.intern(bob);
+
+ assertSame(alice, cache.get(1).orElseThrow());
+ assertSame(bob, cache.get(2).orElseThrow());
+ }
+
+ @Test
+ public void testLightRetention() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.LIGHT);
+ TestEntity entity = new TestEntity(1, "Alice");
+ TestEntity interned = cache.intern(entity);
+ assertSame(entity, interned);
+
+ // Should still be retrievable immediately.
+ Optional result = cache.get(1);
+ assertTrue(result.isPresent());
+ assertSame(entity, result.get());
+ }
+
+ @Test
+ public void testMetricsGetHitRecording() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ cache.intern(new TestEntity(1, "Alice"));
+ cache.get(1);
+
+ EntityCacheMetrics metrics = EntityCacheMetrics.getInstance();
+ assertEquals(1, metrics.getGetHits());
+ }
+
+ @Test
+ public void testMetricsGetMissRecording() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ cache.get(999);
+
+ EntityCacheMetrics metrics = EntityCacheMetrics.getInstance();
+ assertEquals(1, metrics.getGetMisses());
+ }
+
+ @Test
+ public void testMetricsInternHitRecording() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ cache.intern(new TestEntity(1, "Alice"));
+ cache.intern(new TestEntity(1, "Alice"));
+
+ EntityCacheMetrics metrics = EntityCacheMetrics.getInstance();
+ assertEquals(1, metrics.getInternHits());
+ assertEquals(1, metrics.getInternMisses());
+ }
+
+ @Test
+ public void testMetricsInternMissRecording() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ cache.intern(new TestEntity(1, "Alice"));
+
+ EntityCacheMetrics metrics = EntityCacheMetrics.getInstance();
+ assertEquals(1, metrics.getInternMisses());
+ }
+
+ @Test
+ public void testMetricsRemovalRecording() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ cache.intern(new TestEntity(1, "Alice"));
+ cache.remove(1);
+
+ EntityCacheMetrics metrics = EntityCacheMetrics.getInstance();
+ assertEquals(1, metrics.getRemovals());
+ }
+
+ @Test
+ public void testMetricsClearRecording() {
+ EntityCacheImpl cache = new EntityCacheImpl<>(CacheRetention.DEFAULT);
+ cache.intern(new TestEntity(1, "Alice"));
+ cache.clear();
+
+ EntityCacheMetrics metrics = EntityCacheMetrics.getInstance();
+ assertEquals(1, metrics.getClears());
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/spi/OrderableHelperTest.java b/storm-core/src/test/java/st/orm/core/spi/OrderableHelperTest.java
new file mode 100644
index 000000000..6cb1ac139
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/spi/OrderableHelperTest.java
@@ -0,0 +1,188 @@
+package st.orm.core.spi;
+
+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.spi.Orderable.After;
+import st.orm.core.spi.Orderable.AfterAny;
+import st.orm.core.spi.Orderable.Before;
+import st.orm.core.spi.Orderable.BeforeAny;
+
+/**
+ * Tests for {@link OrderableHelper} and {@link Orderable} sorting.
+ */
+public class OrderableHelperTest {
+
+ // Test implementations of Orderable.
+
+ static class BaseOrderable implements Orderable {}
+
+ static class OrderableA extends BaseOrderable {}
+
+ static class OrderableB extends BaseOrderable {}
+
+ static class OrderableC extends BaseOrderable {}
+
+ @Before(OrderableB.class)
+ static class ABeforeB extends BaseOrderable {}
+
+ @After(OrderableA.class)
+ static class CAfterA extends BaseOrderable {}
+
+ @BeforeAny
+ static class FirstOrderable extends BaseOrderable {}
+
+ @AfterAny
+ static class LastOrderable extends BaseOrderable {}
+
+ @Before(OrderableB.class)
+ @After(OrderableA.class)
+ static class BetweenAAndB extends BaseOrderable {}
+
+ // Classes for circular dependency test.
+ @After(CircularB.class)
+ static class CircularA extends BaseOrderable {}
+
+ @After(CircularA.class)
+ static class CircularB extends BaseOrderable {}
+
+ @Test
+ public void testSortEmptyList() {
+ List result = Orderable.sort(List.of());
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testSortSingleElement() {
+ var orderable = new OrderableA();
+ List result = Orderable.sort(List.of(orderable));
+ assertEquals(1, result.size());
+ assertEquals(orderable, result.getFirst());
+ }
+
+ @Test
+ public void testBeforeConstraint() {
+ var beforeItem = new ABeforeB();
+ var orderableB = new OrderableB();
+ List result = Orderable.sort(List.of(orderableB, beforeItem), false);
+ int indexBefore = result.indexOf(beforeItem);
+ int indexB = result.indexOf(orderableB);
+ assertTrue(indexBefore < indexB, "ABeforeB should appear before OrderableB");
+ }
+
+ @Test
+ public void testAfterConstraint() {
+ var orderableA = new OrderableA();
+ var afterItem = new CAfterA();
+ List result = Orderable.sort(List.of(afterItem, orderableA), false);
+ int indexA = result.indexOf(orderableA);
+ int indexAfter = result.indexOf(afterItem);
+ assertTrue(indexA < indexAfter, "OrderableA should appear before CAfterA");
+ }
+
+ @Test
+ public void testBeforeAnyConstraint() {
+ var first = new FirstOrderable();
+ var orderableA = new OrderableA();
+ var orderableB = new OrderableB();
+ List result = Orderable.sort(List.of(orderableA, orderableB, first), false);
+ assertEquals(first, result.getFirst(), "BeforeAny should place the item first");
+ }
+
+ @Test
+ public void testAfterAnyConstraint() {
+ var last = new LastOrderable();
+ var orderableA = new OrderableA();
+ var orderableB = new OrderableB();
+ List result = Orderable.sort(List.of(last, orderableA, orderableB), false);
+ assertEquals(last, result.getLast(), "AfterAny should place the item last");
+ }
+
+ @Test
+ public void testBeforeAnyAndAfterAnyTogether() {
+ var first = new FirstOrderable();
+ var last = new LastOrderable();
+ var middle = new OrderableA();
+ List result = Orderable.sort(List.of(last, middle, first), false);
+ assertEquals(first, result.getFirst(), "BeforeAny item should be first");
+ assertEquals(last, result.getLast(), "AfterAny item should be last");
+ }
+
+ @Test
+ public void testBothBeforeAndAfterConstraints() {
+ var orderableA = new OrderableA();
+ var between = new BetweenAAndB();
+ var orderableB = new OrderableB();
+ List result = Orderable.sort(List.of(orderableB, between, orderableA), false);
+ int indexA = result.indexOf(orderableA);
+ int indexBetween = result.indexOf(between);
+ int indexB = result.indexOf(orderableB);
+ assertTrue(indexA < indexBetween, "OrderableA should come before BetweenAAndB");
+ assertTrue(indexBetween < indexB, "BetweenAAndB should come before OrderableB");
+ }
+
+ @Test
+ public void testCircularDependencyThrowsException() {
+ var circularA = new CircularA();
+ var circularB = new CircularB();
+ assertThrows(IllegalStateException.class,
+ () -> Orderable.sort(List.of(circularA, circularB), false));
+ }
+
+ @Test
+ public void testSortStream() {
+ var first = new FirstOrderable();
+ var last = new LastOrderable();
+ var middle = new OrderableA();
+ List result = Orderable.sort(Stream.of(last, middle, first), false).toList();
+ assertEquals(first, result.getFirst(), "BeforeAny item should be first in stream result");
+ assertEquals(last, result.getLast(), "AfterAny item should be last in stream result");
+ }
+
+ @Test
+ public void testSortStreamWithCache() {
+ var first = new FirstOrderable();
+ var middle = new OrderableA();
+ List result = Orderable.sort(Stream.of(middle, first)).toList();
+ assertEquals(first, result.getFirst(), "BeforeAny item should be first");
+ }
+
+ @Test
+ public void testSortListWithCache() {
+ var first = new FirstOrderable();
+ var middle = new OrderableA();
+ List result = Orderable.sort(List.of(middle, first));
+ assertEquals(first, result.getFirst(), "BeforeAny item should be first");
+ }
+
+ @Test
+ public void testBeforeConstraintOnClassNotInList() {
+ // When the @Before target is not in the list, the constraint should be ignored.
+ var beforeItem = new ABeforeB();
+ var orderableA = new OrderableA();
+ List result = Orderable.sort(List.of(beforeItem, orderableA), false);
+ assertEquals(2, result.size());
+ }
+
+ @Test
+ public void testAfterConstraintOnClassNotInList() {
+ // When the @After target is not in the list, the constraint should be ignored.
+ var afterItem = new CAfterA();
+ var orderableB = new OrderableB();
+ List result = Orderable.sort(List.of(afterItem, orderableB), false);
+ assertEquals(2, result.size());
+ }
+
+ @Test
+ public void testNoConstraintsPreservesRelativeOrder() {
+ var orderableA = new OrderableA();
+ var orderableB = new OrderableB();
+ var orderableC = new OrderableC();
+ List result = Orderable.sort(List.of(orderableA, orderableB, orderableC), false);
+ assertEquals(3, result.size());
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/spi/WeakInternerTest.java b/storm-core/src/test/java/st/orm/core/spi/WeakInternerTest.java
new file mode 100644
index 000000000..c298a14dc
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/spi/WeakInternerTest.java
@@ -0,0 +1,132 @@
+package st.orm.core.spi;
+
+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 org.junit.jupiter.api.Test;
+import st.orm.Entity;
+import st.orm.PK;
+
+/**
+ * Tests for {@link WeakInterner}.
+ */
+public class WeakInternerTest {
+
+ // Simple entity for testing.
+ record TestEntity(@PK Integer id, String name) implements Entity {}
+
+ // A non-entity record for testing.
+ record SimpleData(String value) {}
+
+ @Test
+ public void testInternNonEntityReturnsCanonicalInstance() {
+ WeakInterner interner = new WeakInterner();
+ SimpleData data1 = new SimpleData("hello");
+ SimpleData data2 = new SimpleData("hello");
+
+ SimpleData interned1 = interner.intern(data1);
+ SimpleData interned2 = interner.intern(data2);
+ assertSame(interned1, interned2, "Should return the same canonical instance for equal objects");
+ }
+
+ @Test
+ public void testInternEntityReturnsCanonicalInstance() {
+ WeakInterner interner = new WeakInterner();
+ TestEntity entity1 = new TestEntity(1, "Alice");
+ TestEntity entity2 = new TestEntity(1, "Alice");
+
+ TestEntity interned1 = interner.intern(entity1);
+ TestEntity interned2 = interner.intern(entity2);
+ assertSame(interned1, interned2, "Should return the same canonical instance for equal entities");
+ }
+
+ @Test
+ public void testInternEntityDifferentPrimaryKeyReturnsDifferentInstances() {
+ WeakInterner interner = new WeakInterner();
+ TestEntity entity1 = new TestEntity(1, "Alice");
+ TestEntity entity2 = new TestEntity(2, "Bob");
+
+ TestEntity interned1 = interner.intern(entity1);
+ TestEntity interned2 = interner.intern(entity2);
+ assertSame(entity1, interned1);
+ assertSame(entity2, interned2);
+ }
+
+ @Test
+ public void testInternEntitySamePkReturnsCachedInstance() {
+ WeakInterner interner = new WeakInterner();
+ TestEntity entity1 = new TestEntity(1, "Alice");
+ TestEntity entity2 = new TestEntity(1, "Updated Alice");
+
+ TestEntity interned1 = interner.intern(entity1);
+ assertSame(entity1, interned1);
+ // entity2 has the same PK, so the interner returns the existing cached instance.
+ TestEntity interned2 = interner.intern(entity2);
+ assertSame(entity1, interned2, "Should return cached entity for same PK");
+ }
+
+ @Test
+ public void testInternNullThrowsException() {
+ WeakInterner interner = new WeakInterner();
+ assertThrows(NullPointerException.class, () -> interner.intern(null));
+ }
+
+ @Test
+ public void testGetEntityByTypeAndPk() {
+ WeakInterner interner = new WeakInterner();
+ TestEntity entity = new TestEntity(1, "Alice");
+ interner.intern(entity);
+
+ TestEntity cached = interner.get(TestEntity.class, 1);
+ assertNotNull(cached, "Should find cached entity");
+ assertSame(entity, cached);
+ }
+
+ @Test
+ public void testGetEntityReturnsNullWhenNotCached() {
+ WeakInterner interner = new WeakInterner();
+ TestEntity cached = interner.get(TestEntity.class, 999);
+ assertNull(cached, "Should return null when entity is not cached");
+ }
+
+ @Test
+ public void testInternDifferentNonEntityValues() {
+ WeakInterner interner = new WeakInterner();
+ SimpleData data1 = new SimpleData("hello");
+ SimpleData data2 = new SimpleData("world");
+
+ SimpleData interned1 = interner.intern(data1);
+ SimpleData interned2 = interner.intern(data2);
+ assertSame(data1, interned1);
+ assertSame(data2, interned2);
+ }
+
+ @Test
+ public void testInternStringReturnsCanonicalInstance() {
+ WeakInterner interner = new WeakInterner();
+ // Use new String() to avoid the JVM string pool.
+ String string1 = new String("test");
+ String string2 = new String("test");
+
+ String interned1 = interner.intern(string1);
+ String interned2 = interner.intern(string2);
+ assertSame(interned1, interned2, "Should intern equal strings to the same instance");
+ }
+
+ @Test
+ public void testInternMultipleEntitiesDifferentTypes() {
+ // Test entity of another type.
+ record OtherEntity(@PK Integer id, String label) implements Entity {}
+
+ WeakInterner interner = new WeakInterner();
+ TestEntity testEntity = new TestEntity(1, "Alice");
+ OtherEntity otherEntity = new OtherEntity(1, "Other");
+
+ TestEntity internedTest = interner.intern(testEntity);
+ OtherEntity internedOther = interner.intern(otherEntity);
+ assertSame(testEntity, internedTest);
+ assertSame(otherEntity, internedOther);
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/template/TemplateStringTest.java b/storm-core/src/test/java/st/orm/core/template/TemplateStringTest.java
new file mode 100644
index 000000000..569841106
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/template/TemplateStringTest.java
@@ -0,0 +1,144 @@
+package st.orm.core.template;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link TemplateString}.
+ */
+public class TemplateStringTest {
+
+ @Test
+ public void testOfString() {
+ TemplateString template = TemplateString.of("SELECT 1");
+ assertEquals(List.of("SELECT 1"), template.fragments());
+ assertTrue(template.values().isEmpty());
+ }
+
+ @Test
+ public void testOfFragmentsAndValues() {
+ TemplateString template = TemplateString.of(
+ List.of("SELECT * WHERE id = ", ""),
+ List.of(42)
+ );
+ assertEquals(List.of("SELECT * WHERE id = ", ""), template.fragments());
+ assertEquals(List.of(42), template.values());
+ }
+
+ @Test
+ public void testWrap() {
+ TemplateString wrapped = TemplateString.wrap("value");
+ assertEquals(List.of("", ""), wrapped.fragments());
+ assertEquals(1, wrapped.values().size());
+ assertEquals("value", wrapped.values().getFirst());
+ }
+
+ @Test
+ public void testWrapNull() {
+ TemplateString wrapped = TemplateString.wrap(null);
+ assertEquals(List.of("", ""), wrapped.fragments());
+ assertEquals(1, wrapped.values().size());
+ }
+
+ @Test
+ public void testEmpty() {
+ TemplateString empty = TemplateString.EMPTY;
+ assertEquals(List.of(""), empty.fragments());
+ assertTrue(empty.values().isEmpty());
+ }
+
+ @Test
+ public void testCombineEmpty() {
+ TemplateString combined = TemplateString.combine();
+ assertSame(TemplateString.EMPTY, combined);
+ }
+
+ @Test
+ public void testCombineSingle() {
+ TemplateString single = TemplateString.of("SELECT 1");
+ TemplateString combined = TemplateString.combine(single);
+ assertSame(single, combined);
+ }
+
+ @Test
+ public void testCombineMultiple() {
+ TemplateString first = TemplateString.of(
+ List.of("SELECT * FROM ", ""),
+ List.of("users")
+ );
+ TemplateString second = TemplateString.of(
+ List.of(" WHERE id = ", ""),
+ List.of(42)
+ );
+ TemplateString combined = TemplateString.combine(first, second);
+ assertEquals(3, combined.fragments().size());
+ assertEquals(2, combined.values().size());
+ assertEquals("users", combined.values().get(0));
+ assertEquals(42, combined.values().get(1));
+ }
+
+ @Test
+ public void testCombineList() {
+ TemplateString first = TemplateString.of("SELECT 1");
+ TemplateString second = TemplateString.of(" UNION SELECT 2");
+ TemplateString combined = TemplateString.combine(List.of(first, second));
+ assertEquals(1, combined.fragments().size());
+ assertEquals("SELECT 1 UNION SELECT 2", combined.fragments().getFirst());
+ }
+
+ @Test
+ public void testConstructorValidation() {
+ // Fragments must have exactly one more element than values.
+ assertThrows(IllegalArgumentException.class, () -> new TemplateString(
+ List.of("a", "b", "c"),
+ List.of(1)
+ ));
+ }
+
+ @Test
+ public void testConstructorWithArrays() {
+ TemplateString template = new TemplateString(
+ new String[]{"SELECT ", " FROM ", ""},
+ new Object[]{"*", "users"}
+ );
+ assertEquals(List.of("SELECT ", " FROM ", ""), template.fragments());
+ assertEquals(2, template.values().size());
+ }
+
+ @Test
+ public void testCombineNullThrows() {
+ assertThrows(NullPointerException.class, () -> TemplateString.combine((TemplateString[]) null));
+ }
+
+ @Test
+ public void testCombineWithNullElementThrows() {
+ assertThrows(NullPointerException.class, () -> TemplateString.combine(
+ TemplateString.of("a"), null
+ ));
+ }
+
+ @Test
+ public void testCombineMultipleWithValues() {
+ TemplateString first = TemplateString.of(
+ List.of("a = ", ""),
+ List.of(1)
+ );
+ TemplateString second = TemplateString.of(
+ List.of(" AND b = ", ""),
+ List.of(2)
+ );
+ TemplateString third = TemplateString.of(
+ List.of(" AND c = ", ""),
+ List.of(3)
+ );
+ TemplateString combined = TemplateString.combine(first, second, third);
+ assertEquals(4, combined.fragments().size());
+ assertEquals(3, combined.values().size());
+ assertEquals(List.of(1, 2, 3), combined.values());
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/template/TemplatesTest.java b/storm-core/src/test/java/st/orm/core/template/TemplatesTest.java
new file mode 100644
index 000000000..6a0159f70
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/template/TemplatesTest.java
@@ -0,0 +1,33 @@
+package st.orm.core.template;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+import st.orm.core.model.City;
+
+/**
+ * Tests for {@link Templates} static factory methods.
+ */
+public class TemplatesTest {
+
+ @Test
+ public void testSelect() {
+ assertNotNull(Templates.select(City.class));
+ }
+
+ @Test
+ public void testFromWithAutoJoin() {
+ assertNotNull(Templates.from(City.class, true));
+ }
+
+ @Test
+ public void testInsert() {
+ assertNotNull(Templates.insert(City.class));
+ }
+
+ @Test
+ public void testValues() {
+ City city = new City(null, "Test");
+ assertNotNull(Templates.values(city));
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/BindVarsImplTest.java b/storm-core/src/test/java/st/orm/core/template/impl/BindVarsImplTest.java
new file mode 100644
index 000000000..acc006a79
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/template/impl/BindVarsImplTest.java
@@ -0,0 +1,60 @@
+package st.orm.core.template.impl;
+
+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 org.junit.jupiter.api.Test;
+import st.orm.PersistenceException;
+
+/**
+ * Tests for {@link BindVarsImpl}.
+ */
+public class BindVarsImplTest {
+
+ @Test
+ public void testGetHandle() {
+ BindVarsImpl bindVars = new BindVarsImpl();
+ BindVarsHandle handle = bindVars.getHandle();
+ assertNotNull(handle);
+ }
+
+ @Test
+ public void testHandleThrowsWhenNoBatchListener() {
+ BindVarsImpl bindVars = new BindVarsImpl();
+ BindVarsHandle handle = bindVars.getHandle();
+ // Using handle without setting batch listener should throw.
+ assertThrows(IllegalStateException.class, () -> handle.addBatch(null));
+ }
+
+ @Test
+ public void testSetBatchListenerTwiceThrows() {
+ BindVarsImpl bindVars = new BindVarsImpl();
+ bindVars.setBatchListener(params -> {});
+ assertThrows(PersistenceException.class, () -> bindVars.setBatchListener(params -> {}));
+ }
+
+ @Test
+ public void testSetRecordListenerTwiceThrows() {
+ BindVarsImpl bindVars = new BindVarsImpl();
+ bindVars.setRecordListener(record -> {});
+ assertThrows(PersistenceException.class, () -> bindVars.setRecordListener(record -> {}));
+ }
+
+ @Test
+ public void testToString() {
+ BindVarsImpl bindVars = new BindVarsImpl();
+ String result = bindVars.toString();
+ assertNotNull(result);
+ assertTrue(result.startsWith("BindVarsImpl@"));
+ }
+
+ @Test
+ public void testHandleThrowsWhenNoParameterExtractors() {
+ BindVarsImpl bindVars = new BindVarsImpl();
+ bindVars.setBatchListener(params -> {});
+ BindVarsHandle handle = bindVars.getHandle();
+ // No parameter extractors set.
+ assertThrows(IllegalStateException.class, () -> handle.addBatch(null));
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/EnumMapperTest.java b/storm-core/src/test/java/st/orm/core/template/impl/EnumMapperTest.java
new file mode 100644
index 000000000..3076da0fc
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/template/impl/EnumMapperTest.java
@@ -0,0 +1,87 @@
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import st.orm.PersistenceException;
+import st.orm.core.template.SqlTemplateException;
+
+/**
+ * Tests for {@link EnumMapper}.
+ */
+public class EnumMapperTest {
+
+ enum Color { RED, GREEN, BLUE }
+
+ @Test
+ public void testGetFactoryForNonEnumThrows() {
+ assertThrows(PersistenceException.class, () -> EnumMapper.getFactory(1, String.class));
+ }
+
+ @Test
+ public void testGetFactoryMultiColumnReturnsEmpty() {
+ assertTrue(EnumMapper.getFactory(2, Color.class).isEmpty());
+ }
+
+ @Test
+ public void testGetFactoryReturnsMapperForSingleColumn() {
+ var mapperOptional = EnumMapper.getFactory(1, Color.class);
+ assertTrue(mapperOptional.isPresent());
+ }
+
+ @Test
+ public void testMapperParameterTypes() throws SqlTemplateException {
+ var mapper = EnumMapper.getFactory(1, Color.class).orElseThrow();
+ Class>[] types = mapper.getParameterTypes();
+ assertArrayEquals(new Class>[] { Color.class }, types);
+ }
+
+ @Test
+ public void testMapperFromString() throws SqlTemplateException {
+ var mapper = EnumMapper.getFactory(1, Color.class).orElseThrow();
+ Color result = mapper.newInstance(new Object[] { "GREEN" });
+ assertEquals(Color.GREEN, result);
+ }
+
+ @Test
+ public void testMapperFromOrdinal() throws SqlTemplateException {
+ var mapper = EnumMapper.getFactory(1, Color.class).orElseThrow();
+ Color result = mapper.newInstance(new Object[] { 2 });
+ assertEquals(Color.BLUE, result);
+ }
+
+ @Test
+ public void testMapperFromNull() throws SqlTemplateException {
+ var mapper = EnumMapper.getFactory(1, Color.class).orElseThrow();
+ Color result = mapper.newInstance(new Object[] { null });
+ assertNull(result);
+ }
+
+ @Test
+ public void testMapperInvalidStringThrows() {
+ var mapper = EnumMapper.getFactory(1, Color.class).orElseThrow();
+ assertThrows(SqlTemplateException.class, () -> mapper.newInstance(new Object[] { "PURPLE" }));
+ }
+
+ @Test
+ public void testMapperInvalidOrdinalThrows() {
+ var mapper = EnumMapper.getFactory(1, Color.class).orElseThrow();
+ assertThrows(SqlTemplateException.class, () -> mapper.newInstance(new Object[] { 99 }));
+ }
+
+ @Test
+ public void testMapperInvalidTypeThrows() {
+ var mapper = EnumMapper.getFactory(1, Color.class).orElseThrow();
+ assertThrows(SqlTemplateException.class, () -> mapper.newInstance(new Object[] { 3.14 }));
+ }
+
+ @Test
+ public void testMapperNegativeOrdinalThrows() {
+ var mapper = EnumMapper.getFactory(1, Color.class).orElseThrow();
+ assertThrows(SqlTemplateException.class, () -> mapper.newInstance(new Object[] { -1 }));
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/LazySupplierTest.java b/storm-core/src/test/java/st/orm/core/template/impl/LazySupplierTest.java
new file mode 100644
index 000000000..cac9098e1
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/template/impl/LazySupplierTest.java
@@ -0,0 +1,118 @@
+package st.orm.core.template.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.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link LazySupplier}.
+ */
+public class LazySupplierTest {
+
+ @Test
+ public void testLazyInitialization() {
+ AtomicInteger callCount = new AtomicInteger(0);
+ LazySupplier lazy = new LazySupplier<>(() -> {
+ callCount.incrementAndGet();
+ return "hello";
+ });
+
+ assertEquals(0, callCount.get(), "Supplier should not be called until get()");
+ assertEquals("hello", lazy.get());
+ assertEquals(1, callCount.get(), "Supplier should be called exactly once");
+ assertEquals("hello", lazy.get());
+ assertEquals(1, callCount.get(), "Subsequent get() should not invoke supplier again");
+ }
+
+ @Test
+ public void testLazyStaticFactoryMethod() {
+ AtomicInteger callCount = new AtomicInteger(0);
+ var lazy = LazySupplier.lazy(() -> {
+ callCount.incrementAndGet();
+ return 42;
+ });
+
+ assertEquals(42, lazy.get());
+ assertEquals(1, callCount.get());
+ assertEquals(42, lazy.get());
+ assertEquals(1, callCount.get(), "Static factory lazy supplier should also only invoke once");
+ }
+
+ @Test
+ public void testConstructorWithInitialValue() {
+ AtomicInteger callCount = new AtomicInteger(0);
+ LazySupplier lazy = new LazySupplier<>(() -> {
+ callCount.incrementAndGet();
+ return "fallback";
+ }, "initial");
+
+ assertEquals("initial", lazy.get());
+ assertEquals(0, callCount.get(), "Supplier should not be called when initial value is provided");
+ }
+
+ @Test
+ public void testValueReturnsEmptyBeforeGet() {
+ LazySupplier lazy = new LazySupplier<>(() -> "hello");
+ assertTrue(lazy.value().isEmpty(), "Value should be empty before get() is called");
+ }
+
+ @Test
+ public void testValueReturnsPresentAfterGet() {
+ LazySupplier lazy = new LazySupplier<>(() -> "hello");
+ lazy.get();
+ assertTrue(lazy.value().isPresent(), "Value should be present after get()");
+ assertEquals("hello", lazy.value().get());
+ }
+
+ @Test
+ public void testValueReturnsPresentWithInitialValue() {
+ LazySupplier lazy = new LazySupplier<>(() -> "fallback", "initial");
+ assertTrue(lazy.value().isPresent(), "Value should be present when initial value is provided");
+ assertEquals("initial", lazy.value().get());
+ }
+
+ @Test
+ public void testRemoveClearsLazyValue() {
+ LazySupplier lazy = new LazySupplier<>(() -> "hello");
+ lazy.get();
+ assertTrue(lazy.value().isPresent());
+
+ lazy.remove();
+ assertTrue(lazy.value().isEmpty(), "Value should be empty after remove()");
+ }
+
+ @Test
+ public void testGetAfterRemoveReinvokesSupplier() {
+ AtomicInteger callCount = new AtomicInteger(0);
+ LazySupplier lazy = new LazySupplier<>(() -> {
+ callCount.incrementAndGet();
+ return "hello";
+ });
+
+ lazy.get();
+ assertEquals(1, callCount.get());
+
+ lazy.remove();
+ lazy.get();
+ assertEquals(2, callCount.get(), "After remove, get should invoke supplier again");
+ }
+
+ @Test
+ public void testRequireNonNullElseGet() {
+ assertEquals("existing", LazySupplier.requireNonNullElseGet("existing", () -> "fallback"));
+ assertEquals("fallback", LazySupplier.requireNonNullElseGet(null, () -> "fallback"));
+ }
+
+ @Test
+ public void testRequireNonNullElseGetThrowsOnNullSupplier() {
+ assertThrows(NullPointerException.class, () -> LazySupplier.requireNonNullElseGet(null, null));
+ }
+
+ @Test
+ public void testRequireNonNullElseGetThrowsWhenSupplierReturnsNull() {
+ assertThrows(NullPointerException.class, () -> LazySupplier.requireNonNullElseGet(null, () -> null));
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/LruCacheTest.java b/storm-core/src/test/java/st/orm/core/template/impl/LruCacheTest.java
new file mode 100644
index 000000000..560c03e4e
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/template/impl/LruCacheTest.java
@@ -0,0 +1,151 @@
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link LruCache}.
+ */
+public class LruCacheTest {
+
+ @Test
+ public void testBasicPutAndGet() {
+ LruCache cache = new LruCache<>(3);
+ cache.put("a", "alpha");
+ cache.put("b", "beta");
+ cache.put("c", "gamma");
+ assertEquals("alpha", cache.get("a"));
+ assertEquals("beta", cache.get("b"));
+ assertEquals("gamma", cache.get("c"));
+ }
+
+ @Test
+ public void testEvictionWhenMaxSizeExceeded() {
+ LruCache cache = new LruCache<>(2);
+ cache.put("a", "alpha");
+ cache.put("b", "beta");
+ // Adding a third entry should evict the least recently used (a).
+ cache.put("c", "gamma");
+ assertNull(cache.get("a"), "Eldest entry should have been evicted");
+ assertEquals("beta", cache.get("b"));
+ assertEquals("gamma", cache.get("c"));
+ }
+
+ @Test
+ public void testAccessOrderAffectsEviction() {
+ LruCache cache = new LruCache<>(2);
+ cache.put("a", "alpha");
+ cache.put("b", "beta");
+ // Access "a" so it becomes most recently used.
+ cache.get("a");
+ // Adding "c" should now evict "b" instead of "a".
+ cache.put("c", "gamma");
+ assertEquals("alpha", cache.get("a"), "Accessed entry should not be evicted");
+ assertNull(cache.get("b"), "Least recently used entry should be evicted");
+ assertEquals("gamma", cache.get("c"));
+ }
+
+ @Test
+ public void testMaxSizeOfOne() {
+ LruCache cache = new LruCache<>(1);
+ cache.put(1, "one");
+ assertEquals("one", cache.get(1));
+ cache.put(2, "two");
+ assertNull(cache.get(1), "Previous entry should be evicted when maxSize is 1");
+ assertEquals("two", cache.get(2));
+ }
+
+ @Test
+ public void testMaxSizeZeroThrowsException() {
+ assertThrows(IllegalArgumentException.class, () -> new LruCache<>(0));
+ }
+
+ @Test
+ public void testNegativeMaxSizeThrowsException() {
+ assertThrows(IllegalArgumentException.class, () -> new LruCache<>(-1));
+ }
+
+ @Test
+ public void testOverwriteExistingKey() {
+ LruCache cache = new LruCache<>(3);
+ cache.put("a", "alpha");
+ cache.put("a", "updated");
+ assertEquals("updated", cache.get("a"));
+ assertEquals(1, cache.size());
+ }
+
+ @Test
+ public void testSizeReflectsCurrentEntries() {
+ LruCache cache = new LruCache<>(3);
+ assertEquals(0, cache.size());
+ cache.put("a", "alpha");
+ assertEquals(1, cache.size());
+ cache.put("b", "beta");
+ assertEquals(2, cache.size());
+ cache.put("c", "gamma");
+ assertEquals(3, cache.size());
+ // Should not grow beyond maxSize.
+ cache.put("d", "delta");
+ assertEquals(3, cache.size());
+ }
+
+ @Test
+ public void testContainsKey() {
+ LruCache cache = new LruCache<>(3);
+ cache.put("a", "alpha");
+ assertTrue(cache.containsKey("a"));
+ assertFalse(cache.containsKey("b"));
+ }
+
+ @Test
+ public void testRemove() {
+ LruCache cache = new LruCache<>(3);
+ cache.put("a", "alpha");
+ cache.put("b", "beta");
+ cache.remove("a");
+ assertNull(cache.get("a"));
+ assertEquals(1, cache.size());
+ }
+
+ @Test
+ public void testClear() {
+ LruCache cache = new LruCache<>(3);
+ cache.put("a", "alpha");
+ cache.put("b", "beta");
+ cache.clear();
+ assertEquals(0, cache.size());
+ assertNull(cache.get("a"));
+ }
+
+ @Test
+ public void testConstructorWithFullParameters() {
+ LruCache cache = new LruCache<>(5, 16, 0.75f);
+ cache.put("a", "alpha");
+ assertEquals("alpha", cache.get("a"));
+ }
+
+ @Test
+ public void testEvictionSequence() {
+ LruCache cache = new LruCache<>(3);
+ cache.put(1, "one");
+ cache.put(2, "two");
+ cache.put(3, "three");
+ // All present.
+ assertEquals(3, cache.size());
+ // Add 4, evicts 1 (oldest).
+ cache.put(4, "four");
+ assertNull(cache.get(1));
+ assertEquals("two", cache.get(2));
+ // Add 5, evicts 3 (2 was just accessed by get).
+ cache.put(5, "five");
+ assertNull(cache.get(3));
+ assertEquals("two", cache.get(2));
+ assertEquals("four", cache.get(4));
+ assertEquals("five", cache.get(5));
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/ORMTemplateImplTest.java b/storm-core/src/test/java/st/orm/core/template/impl/ORMTemplateImplTest.java
new file mode 100644
index 000000000..91f9545da
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/template/impl/ORMTemplateImplTest.java
@@ -0,0 +1,51 @@
+package st.orm.core.template.impl;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Type;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import st.orm.Entity;
+import st.orm.core.repository.EntityRepository;
+
+/**
+ * Tests for {@link ORMTemplateImpl} utility methods.
+ */
+public class ORMTemplateImplTest {
+
+ interface TestEntityRepository extends EntityRepository {}
+ record TestEntity(Integer id) implements Entity {}
+
+ interface SubRepository extends TestEntityRepository {}
+
+ interface EmptyRepository {}
+
+ @Test
+ public void testFindGenericTypeDirectInterface() {
+ Optional result = ORMTemplateImpl.findGenericType(
+ TestEntityRepository.class, EntityRepository.class, 0);
+ assertTrue(result.isPresent());
+ }
+
+ @Test
+ public void testFindGenericTypeSubInterface() {
+ Optional result = ORMTemplateImpl.findGenericType(
+ SubRepository.class, EntityRepository.class, 0);
+ assertTrue(result.isPresent());
+ }
+
+ @Test
+ public void testFindGenericTypeNotFound() {
+ Optional result = ORMTemplateImpl.findGenericType(
+ EmptyRepository.class, EntityRepository.class, 0);
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ public void testFindGenericTypeInvalidIndex() {
+ Optional result = ORMTemplateImpl.findGenericType(
+ TestEntityRepository.class, EntityRepository.class, 99);
+ assertFalse(result.isPresent());
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/ObjectMapperFactoryTest.java b/storm-core/src/test/java/st/orm/core/template/impl/ObjectMapperFactoryTest.java
new file mode 100644
index 000000000..7f989a50a
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/template/impl/ObjectMapperFactoryTest.java
@@ -0,0 +1,121 @@
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Parameter;
+import org.junit.jupiter.api.Test;
+import st.orm.Data;
+import st.orm.Ref;
+import st.orm.core.spi.RefFactory;
+import st.orm.core.template.SqlTemplateException;
+
+/**
+ * Tests for {@link ObjectMapperFactory}.
+ */
+public class ObjectMapperFactoryTest {
+
+ private static final RefFactory NULL_REF_FACTORY = new RefFactory() {
+ @Override
+ public Ref create(Class type, ID pk) {
+ return null;
+ }
+ @Override
+ public Ref create(T record, ID pk) {
+ return null;
+ }
+ };
+
+ // Simple class with a matching constructor.
+ public static class SimpleType {
+ private final String value;
+ public SimpleType(String value) {
+ this.value = value;
+ }
+ public String value() { return value; }
+ }
+
+ // Class with no single-arg constructor (only 2-arg).
+ public static class TwoArgType {
+ private final String first;
+ private final String second;
+ public TwoArgType(String first, String second) {
+ this.first = first;
+ this.second = second;
+ }
+ }
+
+ // Class with a StringBuilder parameter.
+ public static class StringBuilderType {
+ private final StringBuilder builder;
+ public StringBuilderType(StringBuilder builder) {
+ this.builder = builder;
+ }
+ public StringBuilder builder() { return builder; }
+ }
+
+ @Test
+ public void testGetObjectMapperForPrimitive() throws SqlTemplateException {
+ var mapper = ObjectMapperFactory.getObjectMapper(1, int.class, NULL_REF_FACTORY);
+ assertTrue(mapper.isPresent());
+ }
+
+ @Test
+ public void testGetObjectMapperForPrimitiveParameterTypes() throws SqlTemplateException {
+ var mapper = ObjectMapperFactory.getObjectMapper(1, int.class, NULL_REF_FACTORY);
+ assertTrue(mapper.isPresent());
+ assertNotNull(mapper.get().getParameterTypes());
+ }
+
+ @Test
+ public void testGetObjectMapperForSimpleClass() throws SqlTemplateException {
+ var mapper = ObjectMapperFactory.getObjectMapper(1, SimpleType.class, NULL_REF_FACTORY);
+ assertTrue(mapper.isPresent());
+ SimpleType instance = mapper.get().newInstance(new Object[]{"hello"});
+ assertEquals("hello", instance.value());
+ }
+
+ @Test
+ public void testGetObjectMapperForTwoArgClass() throws SqlTemplateException {
+ var mapper = ObjectMapperFactory.getObjectMapper(2, TwoArgType.class, NULL_REF_FACTORY);
+ assertTrue(mapper.isPresent());
+ }
+
+ @Test
+ public void testGetObjectMapperNoMatchingConstructor() throws SqlTemplateException {
+ var mapper = ObjectMapperFactory.getObjectMapper(5, SimpleType.class, NULL_REF_FACTORY);
+ assertFalse(mapper.isPresent());
+ }
+
+ @Test
+ public void testGetObjectMapperForStringBuilderType() throws SqlTemplateException {
+ var mapper = ObjectMapperFactory.getObjectMapper(1, StringBuilderType.class, NULL_REF_FACTORY);
+ assertTrue(mapper.isPresent());
+ // The StringBuilder parameter type should be replaced with String in parameterTypes.
+ assertArrayEquals(new Class>[]{String.class}, mapper.get().getParameterTypes());
+ // When creating an instance, a String should be converted to StringBuilder.
+ StringBuilderType instance = mapper.get().newInstance(new Object[]{"test"});
+ assertEquals("test", instance.builder().toString());
+ }
+
+ @Test
+ public void testGetObjectMapperForEnum() throws SqlTemplateException {
+ var mapper = ObjectMapperFactory.getObjectMapper(1, TestEnum.class, NULL_REF_FACTORY);
+ assertTrue(mapper.isPresent());
+ }
+
+ enum TestEnum { A, B, C }
+
+ @Test
+ public void testIsNonnullWithPKAnnotation() throws NoSuchMethodException {
+ // Test parameter annotated with @PK.
+ var constructor = AnnotatedRecord.class.getDeclaredConstructors()[0];
+ Parameter[] parameters = constructor.getParameters();
+ assertTrue(ObjectMapperFactory.isNonnull(parameters[0])); // @PK
+ }
+
+ record AnnotatedRecord(@st.orm.PK int id, String name) {}
+}
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/SegmentedLruCacheTest.java b/storm-core/src/test/java/st/orm/core/template/impl/SegmentedLruCacheTest.java
new file mode 100644
index 000000000..86c2f696d
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/template/impl/SegmentedLruCacheTest.java
@@ -0,0 +1,192 @@
+package st.orm.core.template.impl;
+
+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 org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link SegmentedLruCache}.
+ */
+public class SegmentedLruCacheTest {
+
+ @Test
+ public void testBasicPutAndGet() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ cache.put("a", "alpha");
+ assertEquals("alpha", cache.get("a"));
+ }
+
+ @Test
+ public void testGetReturnsNullForMissingKey() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ assertNull(cache.get("nonexistent"));
+ }
+
+ @Test
+ public void testPutOverwritesExisting() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ cache.put("a", "alpha");
+ cache.put("a", "updated");
+ assertEquals("updated", cache.get("a"));
+ }
+
+ @Test
+ public void testPutIfAbsentWhenAbsent() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ String existing = cache.putIfAbsent("a", "alpha");
+ assertNull(existing, "Should return null when key is absent");
+ assertEquals("alpha", cache.get("a"));
+ }
+
+ @Test
+ public void testPutIfAbsentWhenPresent() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ cache.put("a", "alpha");
+ String existing = cache.putIfAbsent("a", "beta");
+ assertEquals("alpha", existing, "Should return existing value");
+ assertEquals("alpha", cache.get("a"), "Existing value should not be overwritten");
+ }
+
+ @Test
+ public void testGetOrComputeWhenAbsent() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ String result = cache.getOrCompute("a", () -> "computed");
+ assertEquals("computed", result);
+ assertEquals("computed", cache.get("a"));
+ }
+
+ @Test
+ public void testGetOrComputeWhenPresent() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ cache.put("a", "alpha");
+ String result = cache.getOrCompute("a", () -> "computed");
+ assertEquals("alpha", result, "Should return cached value, not recompute");
+ }
+
+ @Test
+ public void testGetOrComputeWithNullSupplierResult() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ String result = cache.getOrCompute("a", () -> null);
+ assertNull(result, "Should return null when supplier returns null");
+ assertNull(cache.get("a"), "Nothing should be cached for null supplier result");
+ }
+
+ @Test
+ public void testRemove() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ cache.put("a", "alpha");
+ String removed = cache.remove("a");
+ assertEquals("alpha", removed);
+ assertNull(cache.get("a"));
+ }
+
+ @Test
+ public void testRemoveReturnsNullForMissingKey() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ assertNull(cache.remove("nonexistent"));
+ }
+
+ @Test
+ public void testClear() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100);
+ cache.put("a", "alpha");
+ cache.put("b", "beta");
+ cache.put("c", "gamma");
+ cache.clear();
+ assertNull(cache.get("a"));
+ assertNull(cache.get("b"));
+ assertNull(cache.get("c"));
+ }
+
+ @Test
+ public void testSegmentCountIsPowerOfTwo() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100, 3);
+ // 3 should be rounded up to 4.
+ assertEquals(4, cache.segmentCount());
+ }
+
+ @Test
+ public void testSegmentCountPowerOfTwoStaysUnchanged() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100, 8);
+ assertEquals(8, cache.segmentCount());
+ }
+
+ @Test
+ public void testDefaultSegmentCountHeuristic() {
+ // For small cache sizes, segment count should be 4 (minimum).
+ SegmentedLruCache smallCache = new SegmentedLruCache<>(10);
+ assertEquals(4, smallCache.segmentCount());
+
+ // For large cache sizes (4096 / 128 = 32), segment count should be 32.
+ SegmentedLruCache largeCache = new SegmentedLruCache<>(4096);
+ assertEquals(32, largeCache.segmentCount());
+
+ // For very large cache sizes, segment count should be capped at 32.
+ SegmentedLruCache veryLargeCache = new SegmentedLruCache<>(100000);
+ assertEquals(32, veryLargeCache.segmentCount());
+ }
+
+ @Test
+ public void testMaxSizeZeroThrowsException() {
+ assertThrows(IllegalArgumentException.class, () -> new SegmentedLruCache<>(0));
+ }
+
+ @Test
+ public void testNegativeMaxSizeThrowsException() {
+ assertThrows(IllegalArgumentException.class, () -> new SegmentedLruCache<>(-1));
+ }
+
+ @Test
+ public void testSegmentCountZeroThrowsException() {
+ assertThrows(IllegalArgumentException.class, () -> new SegmentedLruCache<>(100, 0));
+ }
+
+ @Test
+ public void testNegativeSegmentCountThrowsException() {
+ assertThrows(IllegalArgumentException.class, () -> new SegmentedLruCache<>(100, -1));
+ }
+
+ @Test
+ public void testEvictionWithinSegment() {
+ // Small cache with 1 segment so eviction behavior is deterministic.
+ SegmentedLruCache cache = new SegmentedLruCache<>(2, 1);
+ cache.put(1, "one");
+ cache.put(2, "two");
+ // Adding 3 should evict the least recently used entry.
+ cache.put(3, "three");
+ // One of the earlier entries should be evicted.
+ int nonNullCount = 0;
+ if (cache.get(1) != null) nonNullCount++;
+ if (cache.get(2) != null) nonNullCount++;
+ if (cache.get(3) != null) nonNullCount++;
+ // Should have at most 2 entries since maxSize is 2.
+ assertTrue(nonNullCount <= 2, "Should have at most maxSize entries");
+ assertNotNull(cache.get(3), "Most recently added entry should still be present");
+ }
+
+ @Test
+ public void testMultipleKeysDistributedAcrossSegments() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(100, 4);
+ for (int i = 0; i < 50; i++) {
+ cache.put(i, "value-" + i);
+ }
+ for (int i = 0; i < 50; i++) {
+ assertEquals("value-" + i, cache.get(i));
+ }
+ }
+
+ @Test
+ public void testMaxSizeOneWithOneSegment() {
+ SegmentedLruCache cache = new SegmentedLruCache<>(1, 1);
+ assertEquals(1, cache.segmentCount());
+ cache.put("a", "alpha");
+ assertEquals("alpha", cache.get("a"));
+ cache.put("b", "beta");
+ assertNull(cache.get("a"), "First entry should be evicted when maxSize is 1");
+ assertEquals("beta", cache.get("b"));
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/SqlImplTest.java b/storm-core/src/test/java/st/orm/core/template/impl/SqlImplTest.java
new file mode 100644
index 000000000..1ed009507
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/template/impl/SqlImplTest.java
@@ -0,0 +1,200 @@
+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 java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import st.orm.core.template.Sql;
+import st.orm.core.template.SqlOperation;
+
+/**
+ * Tests for {@link SqlImpl}.
+ */
+public class SqlImplTest {
+
+ private SqlImpl createBasicSql() {
+ return new SqlImpl(
+ SqlOperation.SELECT,
+ "SELECT * FROM users",
+ List.of(),
+ Optional.empty(),
+ List.of(),
+ Optional.empty(),
+ false,
+ Optional.empty()
+ );
+ }
+
+ @Test
+ public void testBasicConstruction() {
+ SqlImpl sql = createBasicSql();
+ assertEquals(SqlOperation.SELECT, sql.operation());
+ assertEquals("SELECT * FROM users", sql.statement());
+ assertTrue(sql.parameters().isEmpty());
+ assertTrue(sql.bindVariables().isEmpty());
+ assertTrue(sql.generatedKeys().isEmpty());
+ assertTrue(sql.affectedType().isEmpty());
+ assertFalse(sql.versionAware());
+ assertTrue(sql.unsafeWarning().isEmpty());
+ }
+
+ @Test
+ public void testOperationReturnsNewInstance() {
+ SqlImpl sql = createBasicSql();
+ Sql updated = sql.operation(SqlOperation.INSERT);
+ assertEquals(SqlOperation.INSERT, updated.operation());
+ assertEquals("SELECT * FROM users", updated.statement());
+ }
+
+ @Test
+ public void testStatementReturnsNewInstance() {
+ SqlImpl sql = createBasicSql();
+ Sql updated = sql.statement("INSERT INTO users VALUES (?)");
+ assertEquals("INSERT INTO users VALUES (?)", updated.statement());
+ assertEquals(SqlOperation.SELECT, updated.operation());
+ }
+
+ @Test
+ public void testParametersReturnsNewInstance() {
+ SqlImpl sql = createBasicSql();
+ Sql updated = sql.parameters(List.of());
+ assertNotNull(updated);
+ assertTrue(updated.parameters().isEmpty());
+ }
+
+ @Test
+ public void testBindVariablesReturnsNewInstance() {
+ SqlImpl sql = createBasicSql();
+ Sql updated = sql.bindVariables(null);
+ assertTrue(updated.bindVariables().isEmpty());
+ }
+
+ @Test
+ public void testGeneratedKeysReturnsNewInstance() {
+ SqlImpl sql = createBasicSql();
+ Sql updated = sql.generatedKeys(List.of("id", "created_at"));
+ assertEquals(List.of("id", "created_at"), updated.generatedKeys());
+ }
+
+ @Test
+ public void testAffectedTypeReturnsNewInstance() {
+ SqlImpl sql = createBasicSql();
+ Sql updated = sql.affectedType(null);
+ assertTrue(updated.affectedType().isEmpty());
+ }
+
+ @Test
+ public void testVersionAwareReturnsNewInstance() {
+ SqlImpl sql = createBasicSql();
+ Sql updated = sql.versionAware(true);
+ assertTrue(updated.versionAware());
+ assertFalse(sql.versionAware());
+ }
+
+ @Test
+ public void testUnsafeWarningReturnsNewInstance() {
+ SqlImpl sql = createBasicSql();
+ Sql updated = sql.unsafeWarning("This query is unsafe");
+ assertTrue(updated.unsafeWarning().isPresent());
+ assertEquals("This query is unsafe", updated.unsafeWarning().get());
+ }
+
+ @Test
+ public void testUnsafeWarningNullClearsWarning() {
+ SqlImpl sql = new SqlImpl(
+ SqlOperation.SELECT,
+ "SELECT * FROM users",
+ List.of(),
+ Optional.empty(),
+ List.of(),
+ Optional.empty(),
+ false,
+ Optional.of("existing warning")
+ );
+ Sql updated = sql.unsafeWarning(null);
+ assertTrue(updated.unsafeWarning().isEmpty());
+ }
+
+ @Test
+ public void testNullOperationThrows() {
+ assertThrows(NullPointerException.class, () -> new SqlImpl(
+ null,
+ "SELECT 1",
+ List.of(),
+ Optional.empty(),
+ List.of(),
+ Optional.empty(),
+ false,
+ Optional.empty()
+ ));
+ }
+
+ @Test
+ public void testNullStatementThrows() {
+ assertThrows(NullPointerException.class, () -> new SqlImpl(
+ SqlOperation.SELECT,
+ null,
+ List.of(),
+ Optional.empty(),
+ List.of(),
+ Optional.empty(),
+ false,
+ Optional.empty()
+ ));
+ }
+
+ @Test
+ public void testAllOperationTypes() {
+ for (SqlOperation operation : SqlOperation.values()) {
+ SqlImpl sql = new SqlImpl(
+ operation,
+ "SQL",
+ List.of(),
+ Optional.empty(),
+ List.of(),
+ Optional.empty(),
+ false,
+ Optional.empty()
+ );
+ assertEquals(operation, sql.operation());
+ }
+ }
+
+ @Test
+ public void testParametersAreDefensivelyCopied() {
+ List