diff --git a/src/main/java/org/redis/RedisMap.java b/src/main/java/org/redis/RedisMap.java new file mode 100644 index 0000000..7fdf943 --- /dev/null +++ b/src/main/java/org/redis/RedisMap.java @@ -0,0 +1,280 @@ +package org.redis; + +import redis.clients.jedis.Jedis; + +import java.util.*; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * Redis-backed Map. Changes are buffered. + * Call flushBuffer() or close() to sync with Redis. + * Not thread-safe. + */ +public class RedisMap implements Map, AutoCloseable { + + private final Jedis jedis; + private final Map writeBuffer; + private final String prefix; + private final boolean isOwnJedis; + + /** + * Creates a connection to Redis. + * + * @param host Redis host + * @param port Redis port + * @param prefix prefix for keys (may be null) + */ + public RedisMap(String host, int port, String prefix) { + this(createJedis(host, port), prefix, true); + } + + /** + * Uses an existing Redis connection. + * + * @param jedis Jedis client + * @param prefix prefix for keys (may be null) + */ + public RedisMap(Jedis jedis, String prefix) { + this(jedis, prefix, false); + } + + + private RedisMap(Jedis jedis, String prefix, boolean isOwnJedis) { + Objects.requireNonNull(jedis, "Jedis is null!"); + this.jedis = jedis; + this.prefix = (prefix == null) ? "" : prefix; + this.isOwnJedis = isOwnJedis; + writeBuffer = new HashMap<>(); + Logger logger = Logger.getLogger(RedisMap.class.getName()); + logger.info(jedis.ping()); + } + + /** + * Creates a Jedis client. + * Checks host and port. + */ + private static Jedis createJedis(String host, int port) { + Objects.requireNonNull(host, "Host is null!"); + if (port <= 0 || port > 65535) { + throw new IllegalArgumentException("Invalid port: " + port); + } + return new Jedis(host, port); + } + + /** + * @return the number of records with this prefix. + */ + @Override + public int size() { + return entrySet().size(); + } + + /** + * @return true if jedis is empty. + */ + @Override + public boolean isEmpty() { + return this.size() == 0; + } + + /** + * @param key the key to check. + * @return true if the key exists in Redis. + */ + @Override + public boolean containsKey(Object key) { + String k = checkNullThrowException(key, "key"); + if (writeBuffer.containsKey(k)) { + return writeBuffer.get(k) != null; + } + return jedis.exists(getFullKey((String) key)); + } + + /** + * @param value the value to look up. + * @return true if the value is found in Redis. + */ + @Override + public boolean containsValue(Object value) { + return entrySet().stream() + .anyMatch(entry -> value.equals(entry.getValue())); + } + + /** + * @param key key. + * @return value from buffer or Redis. + */ + @Override + public String get(Object key) { + String k = checkNullThrowException(key, "key"); + if (writeBuffer.containsKey(k)) { + return writeBuffer.get(k); + } + return jedis.get(getFullKey((String) key)); + } + + /** + * Adds a value to the buffer. + * Writes to Redis - on flushBuffer(). + * + * @param key key + * @param value value + * @return previous value + */ + @Override + public String put(String key, String value) { + return writeBuffer.put(checkNullThrowException(key, "key"), value); + } + + /** + * Marks a key for deletion. + * Delete from Redis - on flushBuffer(). + * + * @param key key + * @return old value + */ + @Override + public String remove(Object key) { + return writeBuffer.put(checkNullThrowException(key, "key"), null); + } + + /** + * Adds all pairs from the map. + * + * @param m map with data + */ + @Override + public void putAll(Map m) { + HashMap savedWriteBuffer = new HashMap<>(Map.copyOf(writeBuffer)); + try { + for (var entry : m.entrySet()) { + this.put(entry.getKey(), entry.getValue()); + } + } catch (NullPointerException e) { + writeBuffer.clear(); + writeBuffer.putAll(savedWriteBuffer); + throw new NullPointerException(e.getMessage()); + } + } + + /** + * Removes all entries with this prefix from Redis. + * Clears the buffer. + */ + @Override + public void clear() { + Set keys = jedis.keys(getFullKey("*")); + for (String key : keys) { + jedis.del(key); + } + writeBuffer.clear(); + } + + /** + * @return all keys (from Redis and buffer). + */ + @Override + public Set keySet() { + return entrySet().stream() + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + /** + * @return all values (from Redis and buffer). + */ + @Override + public Collection values() { + return entrySet().stream() + .map(Map.Entry::getValue) + .toList(); + } + + /** + * @return all records (from Redis and buffer). + */ + @Override + public Set> entrySet() { + Set redisKeys = jedis.keys(getFullKey("*")); + Map result = new HashMap<>(); + + for (String fullKey : redisKeys) { + String key = getStripPrefix(fullKey); + String value = jedis.get(fullKey); + if (value != null) { + result.put(key, value); + } + } + + for (Map.Entry entry : writeBuffer.entrySet()) { + if (entry.getValue() == null) { + result.remove(entry.getKey()); + } else { + result.put(entry.getKey(), entry.getValue()); + } + } + + return result.entrySet().stream() + .map(e -> new AbstractMap.SimpleImmutableEntry<>(e.getKey(), e.getValue())) + .collect(java.util.stream.Collectors.toSet()); + } + + /** + * Synchronizes the buffer with Redis. + * If null, deletes the key. + * After synchronization, the buffer is cleared. + */ + public void flushBuffer() { + for (var entry : writeBuffer.entrySet()) { + String fullKey = getFullKey(entry.getKey()); + if (entry.getValue() == null) { + jedis.del(fullKey); + } else { + jedis.set(fullKey, entry.getValue()); + } + } + writeBuffer.clear(); + } + + /** + * @param key key. + * @return key with prefix. + */ + private String getFullKey(String key) { + return (prefix.isEmpty()) ? key : prefix + ":" + key; + } + + /** + * @param fullKey key with prefix. + * @return key without prefix. + */ + private String getStripPrefix(String fullKey) { + if (prefix.isEmpty()) return fullKey; + return fullKey.startsWith(prefix + ":") + ? fullKey.substring((prefix + ":").length()) + : fullKey; + } + + /** + * Null-safe cast to String. Throws NPE if object is null. + */ + private String checkNullThrowException(Object object, String nameValue) { + if (object == null) { + throw new NullPointerException(nameValue + " cannot be null!"); + } + return (String) object; + } + + /** + * Flushes the buffer and closes the connection, + * if it was created inside the class. + */ + @Override + public void close() { + flushBuffer(); + if (isOwnJedis) { + jedis.close(); + } + } +} diff --git a/src/test/java/org/redis/RedisMapTest.java b/src/test/java/org/redis/RedisMapTest.java new file mode 100644 index 0000000..049baa3 --- /dev/null +++ b/src/test/java/org/redis/RedisMapTest.java @@ -0,0 +1,177 @@ +package org.redis; + +import org.junit.jupiter.api.*; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import redis.clients.jedis.Jedis; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@Testcontainers +class RedisMapTest { + + @Container + private static final GenericContainer container = + new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379); + private RedisMap redisMap; + private Jedis jedis; + + @AfterAll + static void shutdown() { + container.close(); + } + + @BeforeEach + void setUp() { + String host = container.getHost(); + int port = container.getFirstMappedPort(); + jedis = new Jedis(host, port); + redisMap = new RedisMap(jedis, "test"); + } + + @AfterEach + void tearDown() { + if (redisMap != null) { + redisMap.clear(); + redisMap.close(); + } + if (jedis != null) { + jedis.close(); + } + } + + @Test + @DisplayName("entrySet() should reflect Redis and buffer correctly") + void entrySet_shouldReflectRedisAndBuffer() { + // Given: data in Redis + jedis.set("test:name", "Alice"); + jedis.set("test:age", "30"); + + redisMap.put("age", "31"); + redisMap.remove("temp"); // not exists + redisMap.put("status", "active"); + + // Then + Set> entries = redisMap.entrySet(); + assertTrue(entries.contains(Map.entry("name", "Alice"))); + assertTrue(entries.contains(Map.entry("age", "31"))); + assertTrue(entries.contains(Map.entry("status", "active"))); + assertFalse(entries.stream().anyMatch(e -> "temp".equals(e.getKey())), "temp should be removed"); + assertEquals(3, entries.size()); + } + + @Test + @DisplayName("put() and remove() should return previous value from buffer") + void putAndRemove_shouldReturnPreviousValue() { + assertNull(redisMap.put("key", "v1")); + assertEquals("v1", redisMap.put("key", "v2")); + assertEquals("v2", redisMap.remove("key")); + assertNull(redisMap.remove("key")); + } + + @Test + @DisplayName("get() should return buffered value, not Redis") + void get_shouldPreferBuffer() { + jedis.set("test:flag", "old"); + redisMap.put("flag", "new"); + assertEquals("new", redisMap.get("flag")); + } + + @Test + @DisplayName("flushBuffer() should sync buffer to Redis and clear buffer state") + void flushBuffer_shouldSyncToRedisAndClearBuffer() { + // Given: buffer has updates + redisMap.put("x", "X"); + redisMap.remove("y"); + jedis.set("test:y", "Y"); + + redisMap.flushBuffer(); + + assertEquals("X", jedis.get("test:x"), "Key 'x' should be written to Redis"); + assertNull(jedis.get("test:y"), "Key 'y' should be deleted from Redis"); + + assertEquals("X", redisMap.get("x"), "get() should return value from Redis after flush"); + assertNull(redisMap.get("y"), "get() should return null for deleted key"); + + assertTrue(redisMap.containsKey("x"), "Map should contain 'x' after flush"); + assertFalse(redisMap.containsKey("y"), "Map should not contain 'y' after removal"); + + Set> entries = redisMap.entrySet(); + assertTrue(entries.contains(Map.entry("x", "X")), "entrySet should include 'x'"); + assertFalse(entries.stream().anyMatch(e -> "y".equals(e.getKey())), "entrySet should not include 'y'"); + } + + @Test + @DisplayName("close() should flush and close own connection") + void close_shouldFlushAndClose() { + RedisMap localMap = new RedisMap("localhost", container.getFirstMappedPort(), "tmp"); + + localMap.put("data", "final"); + localMap.close(); // flush + close + + try (Jedis check = new Jedis("localhost", container.getFirstMappedPort())) { + assertEquals("final", check.get("tmp:data")); + } + } + + @Test + @DisplayName("putAll() should rollback on null key") + void putAll_shouldRollbackOnNull() { + redisMap.put("safe", "ok"); + + Map withNull = new HashMap<>(); + withNull.put("valid", "value"); + withNull.put(null, "bad"); // NPE + + assertThrows(NullPointerException.class, () -> redisMap.putAll(withNull)); + assertEquals("ok", redisMap.get("safe")); + assertNull(redisMap.get("valid")); // rollback + } + + @Test + @DisplayName("clear() should remove all prefixed keys from Redis") + void clear_shouldRemovePrefixedKeys() { + jedis.set("test:a", "1"); + jedis.set("test:b", "2"); + jedis.set("other:c", "3"); // not affected + + redisMap.clear(); + + assertNull(jedis.get("test:a")); + assertNull(jedis.get("test:b")); + assertEquals("3", jedis.get("other:c")); + } + + @Test + @DisplayName("methods should throw NPE on null key") + void shouldThrowOnNullKey() { + assertAll( + () -> assertThrows(NullPointerException.class, () -> redisMap.get(null)), + () -> assertThrows(NullPointerException.class, () -> redisMap.put(null, "v")), + () -> assertThrows(NullPointerException.class, () -> redisMap.remove(null)), + () -> assertThrows(NullPointerException.class, () -> redisMap.containsKey(null)) + ); + } + + @Test + @DisplayName("different prefixes should not interfere") + void prefix_shouldIsolateNamespaces() { + RedisMap map1 = new RedisMap(jedis, "user"); + RedisMap map2 = new RedisMap(jedis, "session"); + + map1.put("id", "1"); + map2.put("id", "abc"); + + map1.flushBuffer(); + map2.flushBuffer(); + assertEquals("1", jedis.get("user:id")); + assertEquals("abc", jedis.get("session:id")); + } + +}