From 5e7f42b76e8b6a8a1bab8eae22a17f109464394b Mon Sep 17 00:00:00 2001 From: Braydon Date: Tue, 12 Dec 2023 19:03:14 -0500 Subject: [PATCH] feat(database): Add Redis support --- pom.xml | 12 ++ .../me/braydon/feather/FeatherSettings.java | 9 ++ .../me/braydon/feather/FeatherThreads.java | 2 +- .../java/me/braydon/feather/IDatabase.java | 14 ++ .../feather/annotation/Collection.java | 21 +++ .../me/braydon/feather/annotation/Field.java | 25 +++ .../me/braydon/feather/annotation/Id.java | 22 +++ .../me/braydon/feather/data/Document.java | 94 +++++++++++ .../feather/databases/mongodb/MongoDB.java | 40 ++++- .../databases/mongodb/MongoSyncPipeline.java | 4 +- .../feather/databases/redis/Redis.java | 149 ++++++++++++++++++ .../databases/redis/RedisPipeline.java | 20 +++ .../feather/repository/Repository.java | 23 +++ 13 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 src/main/java/me/braydon/feather/annotation/Collection.java create mode 100644 src/main/java/me/braydon/feather/annotation/Field.java create mode 100644 src/main/java/me/braydon/feather/annotation/Id.java create mode 100644 src/main/java/me/braydon/feather/data/Document.java create mode 100644 src/main/java/me/braydon/feather/databases/redis/Redis.java create mode 100644 src/main/java/me/braydon/feather/databases/redis/RedisPipeline.java create mode 100644 src/main/java/me/braydon/feather/repository/Repository.java diff --git a/pom.xml b/pom.xml index f57ea14..dde6155 100644 --- a/pom.xml +++ b/pom.xml @@ -182,6 +182,12 @@ 32.1.3-jre compile + + com.google.code.gson + gson + 2.10.1 + compile + @@ -190,5 +196,11 @@ 4.11.1 compile + + io.lettuce + lettuce-core + 6.3.0.RELEASE + compile + \ No newline at end of file diff --git a/src/main/java/me/braydon/feather/FeatherSettings.java b/src/main/java/me/braydon/feather/FeatherSettings.java index 25b1d26..e263150 100644 --- a/src/main/java/me/braydon/feather/FeatherSettings.java +++ b/src/main/java/me/braydon/feather/FeatherSettings.java @@ -1,5 +1,7 @@ package me.braydon.feather; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import lombok.Getter; import lombok.Setter; @@ -13,4 +15,11 @@ public final class FeatherSettings { * The amount of threads to use for {@link FeatherThreads}. */ @Setter @Getter private static int threadCount = 4; + + /** + * The {@link Gson} instance to use for serialization. + */ + @Setter @Getter private static Gson gson = new GsonBuilder() + .serializeNulls() + .create(); } \ No newline at end of file diff --git a/src/main/java/me/braydon/feather/FeatherThreads.java b/src/main/java/me/braydon/feather/FeatherThreads.java index 7d3cbe0..8905bb0 100644 --- a/src/main/java/me/braydon/feather/FeatherThreads.java +++ b/src/main/java/me/braydon/feather/FeatherThreads.java @@ -15,6 +15,6 @@ import java.util.concurrent.atomic.AtomicInteger; public final class FeatherThreads { private static final AtomicInteger ID = new AtomicInteger(0); // The thread id public static final ExecutorService THREAD_POOL = Executors.newFixedThreadPool(FeatherSettings.getThreadCount(), new ThreadFactoryBuilder() - .setNameFormat("Feather Thread #" + (ID.incrementAndGet())) + .setNameFormat("Feather Thread #" + ID.incrementAndGet()) .build()); // The thread pool to execute on } \ No newline at end of file diff --git a/src/main/java/me/braydon/feather/IDatabase.java b/src/main/java/me/braydon/feather/IDatabase.java index f6e9d05..d153c98 100644 --- a/src/main/java/me/braydon/feather/IDatabase.java +++ b/src/main/java/me/braydon/feather/IDatabase.java @@ -1,6 +1,8 @@ package me.braydon.feather; import lombok.NonNull; +import me.braydon.feather.annotation.Collection; +import me.braydon.feather.annotation.Field; import java.io.Closeable; @@ -61,4 +63,16 @@ public interface IDatabase extends Closeable { * @see A for asynchronous pipeline */ @NonNull A async(); + + /** + * Write the given object to the database. + *

+ * This object is an instance of a class + * annotated with {@link Collection}, and + * contains fields annotated with {@link Field}. + *

+ * + * @param element the element to write + */ + void write(@NonNull Object element); } \ No newline at end of file diff --git a/src/main/java/me/braydon/feather/annotation/Collection.java b/src/main/java/me/braydon/feather/annotation/Collection.java new file mode 100644 index 0000000..c11c634 --- /dev/null +++ b/src/main/java/me/braydon/feather/annotation/Collection.java @@ -0,0 +1,21 @@ +package me.braydon.feather.annotation; + +import java.lang.annotation.*; + +/** + * Classes tagged with this annotation + * will be treated as a collection. + * + * @author Braydon + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented @Inherited +public @interface Collection { + /** + * The name of this collection. + * + * @return the name + */ + String name() default ""; +} \ No newline at end of file diff --git a/src/main/java/me/braydon/feather/annotation/Field.java b/src/main/java/me/braydon/feather/annotation/Field.java new file mode 100644 index 0000000..5477af6 --- /dev/null +++ b/src/main/java/me/braydon/feather/annotation/Field.java @@ -0,0 +1,25 @@ +package me.braydon.feather.annotation; + +import java.lang.annotation.*; + +/** + * Fields tagged with this annotation will be + * treated as a field within a {@link Collection}. + * + * @author Braydon + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD }) +@Documented @Inherited +public @interface Field { + /** + * The key of this field. + *

+ * If empty, the field name + * will be used as the key. + *

+ * + * @return the key + */ + String key() default ""; +} \ No newline at end of file diff --git a/src/main/java/me/braydon/feather/annotation/Id.java b/src/main/java/me/braydon/feather/annotation/Id.java new file mode 100644 index 0000000..17cbd33 --- /dev/null +++ b/src/main/java/me/braydon/feather/annotation/Id.java @@ -0,0 +1,22 @@ +package me.braydon.feather.annotation; + +import java.lang.annotation.*; + +/** + * Fields tagged with this annotation will be treated + * as the primary identifying key for documents within + * a {@link Collection}. + * + * @author Braydon + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented @Inherited +public @interface Id { + /** + * The key of this field. + * + * @return the key + */ + String key() default "_id"; +} \ No newline at end of file diff --git a/src/main/java/me/braydon/feather/data/Document.java b/src/main/java/me/braydon/feather/data/Document.java new file mode 100644 index 0000000..c377fb0 --- /dev/null +++ b/src/main/java/me/braydon/feather/data/Document.java @@ -0,0 +1,94 @@ +package me.braydon.feather.data; + +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; +import me.braydon.feather.FeatherSettings; +import me.braydon.feather.IDatabase; +import me.braydon.feather.annotation.Collection; +import me.braydon.feather.annotation.Field; +import me.braydon.feather.annotation.Id; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * A document is a key-value pair that is stored + * within a {@link Collection}. This document is + * based on the Bson {@link org.bson.Document} + * in MongoDB, however this document is universal + * between all {@link IDatabase}'s. + * + * @author Braydon + * @param the type of value this document holds + */ +@Getter @ToString +public class Document { + /** + * The key to use for the id field. + */ + @NonNull private final String idKey; + + /** + * The key of this document. + */ + @NonNull private final Object key; + + /** + * The mapped data of this document. + * + * @see V for value type + */ + private final Map mappedData = Collections.synchronizedMap(new LinkedHashMap<>()); + + public Document(@NonNull Object element, boolean rawObject) { + Class clazz = element.getClass(); // Get the element class + String idKey = null; // The key for the id field + for (java.lang.reflect.Field field : clazz.getDeclaredFields()) { + // Field is missing the @Field annotation, skip it + if (!field.isAnnotationPresent(Field.class)) { + continue; + } + field.setAccessible(true); // Make our field accessible + boolean idField = field.isAnnotationPresent(Id.class); // Is this field annotated with @Id? + Field annotation = field.getAnnotation(Field.class); // Get the @Field annotation + String key = idField ? field.getAnnotation(Id.class).key() : annotation.key(); // The key of the database field + if (key.isEmpty()) { // No field in the annotation, use the field name + key = field.getName(); + } + // The field is annotated with @Id, save it for later + if (idField) { + idKey = key; + } + Class fieldType = field.getType(); // The type of the field + try { + Object value; // The value of the field + + if (fieldType == UUID.class) { // Convert UUIDs into strings + value = ((UUID) field.get(element)).toString(); + } else if (rawObject) { // Use the raw object from the field + value = field.get(element); + } else { // Otherwise, turn the value into a string + if (fieldType == String.class) { // Already a string, cast it + value = field.get(element); + } else { // Convert the field into json using Gson + value = FeatherSettings.getGson().toJson(field.get(element)); + } + } + mappedData.put(key, (V) value); // Store in our map + } catch (IllegalAccessException ex) { + throw new RuntimeException(ex); + } + } + assert idKey != null; // We need an id key + this.idKey = idKey; // We have our id key + + V key = mappedData.get(idKey); // Get the id from the data map + if (key == null) { // The element is missing an id field + throw new IllegalArgumentException("No @Id annotated field found in " + clazz.getSimpleName()); + } + this.key = key; + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/feather/databases/mongodb/MongoDB.java b/src/main/java/me/braydon/feather/databases/mongodb/MongoDB.java index 53cd4b8..cbb4c17 100644 --- a/src/main/java/me/braydon/feather/databases/mongodb/MongoDB.java +++ b/src/main/java/me/braydon/feather/databases/mongodb/MongoDB.java @@ -3,10 +3,16 @@ package me.braydon.feather.databases.mongodb; import com.mongodb.ConnectionString; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.UpdateOptions; import lombok.Getter; import lombok.NonNull; import me.braydon.feather.IDatabase; +import me.braydon.feather.annotation.Collection; +import me.braydon.feather.annotation.Field; +import me.braydon.feather.data.Document; /** * The {@link IDatabase} implementation for MongoDB. @@ -16,7 +22,7 @@ import me.braydon.feather.IDatabase; * @see ConnectionString for the credentials class * @see MongoSyncPipeline for the sync pipeline class * @see MongoAsyncPipeline for the async pipeline class - * @see MongoDB Official Site + * @see MongoDB Official Site */ public class MongoDB implements IDatabase { /** @@ -109,6 +115,38 @@ public class MongoDB implements IDatabase + * This object is an instance of a class + * annotated with {@link Collection}, and + * contains fields annotated with {@link Field}. + *

+ * + * @param element the element to write + */ + @Override + public void write(@NonNull Object element) { + Class clazz = element.getClass(); // Get the element class + if (!clazz.isAnnotationPresent(Collection.class)) { // Missing annotation + throw new IllegalStateException("Element is missing @Collection annotation"); + } + Collection annotation = clazz.getAnnotation(Collection.class); // Get the @Collection annotation + String collectionName = annotation.name(); // The name of the collection + if (collectionName.isEmpty()) { // Missing collection name + throw new IllegalStateException("Missing collection name in @Collection for " + clazz.getSimpleName()); + } + MongoCollection collection = database.getCollection(collectionName); // Get the collection + Document document = new Document<>(element, true); // Construct the document from the element + + // Set the map in the database + collection.updateOne( + Filters.eq(document.getIdKey(), document.getKey()), + new org.bson.Document("$set", new org.bson.Document(document.getMappedData())), + new UpdateOptions().upsert(true) + ); + } + /** * Closes this stream and releases any system resources associated * with it. If the stream is already closed then invoking this diff --git a/src/main/java/me/braydon/feather/databases/mongodb/MongoSyncPipeline.java b/src/main/java/me/braydon/feather/databases/mongodb/MongoSyncPipeline.java index 89bbb4f..762f178 100644 --- a/src/main/java/me/braydon/feather/databases/mongodb/MongoSyncPipeline.java +++ b/src/main/java/me/braydon/feather/databases/mongodb/MongoSyncPipeline.java @@ -6,7 +6,7 @@ import lombok.AllArgsConstructor; import lombok.NonNull; /** - * The pipeline for handling {@link MongoDB} operations. + * The pipeline for handling synchronous {@link MongoDB} operations. * * @author Braydon */ @@ -14,6 +14,8 @@ import lombok.NonNull; public final class MongoSyncPipeline { /** * The database to handle operations for. + * + * @see MongoDB for database */ @NonNull private final MongoDB database; diff --git a/src/main/java/me/braydon/feather/databases/redis/Redis.java b/src/main/java/me/braydon/feather/databases/redis/Redis.java new file mode 100644 index 0000000..70936de --- /dev/null +++ b/src/main/java/me/braydon/feather/databases/redis/Redis.java @@ -0,0 +1,149 @@ +package me.braydon.feather.databases.redis; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.api.async.RedisAsyncCommands; +import io.lettuce.core.api.sync.RedisCommands; +import lombok.NonNull; +import me.braydon.feather.IDatabase; +import me.braydon.feather.annotation.Collection; +import me.braydon.feather.annotation.Field; +import me.braydon.feather.data.Document; + +/** + * The {@link IDatabase} implementation for Redis. + * + * @author Braydon + * @see StatefulRedisConnection for the bootstrap class + * @see RedisURI for the credentials class + * @see RedisCommands for the sync pipeline class + * @see RedisAsyncCommands for the async pipeline class + * @see Redis Official Site + */ +public class Redis implements IDatabase, RedisURI, RedisCommands, RedisAsyncCommands> { + /** + * The current {@link RedisClient} instance. + */ + private RedisClient client; + + /** + * The current established {@link StatefulRedisConnection}. + */ + private StatefulRedisConnection connection; + + /** + * Get the name of this database. + * + * @return the database name + */ + @Override @NonNull + public String getName() { + return "Redis"; + } + + /** + * Initialize a connection to this database. + * + * @param credentials the optional credentials to use + */ + @Override + public void connect(RedisURI credentials) { + if (credentials == null) { // We need valid credentials + throw new IllegalArgumentException("No credentials defined"); + } + if (isConnected()) { // Already connected + throw new IllegalStateException("Already connected"); + } + if (client != null) { // We have a client, close it first + client.close(); + } + if (connection != null) { // We have a connection, close it first + connection.close(); + } + client = RedisClient.create(credentials); // Create a new client + connection = client.connect(); // Connect to the Redis server + } + + /** + * Check if this database is connected. + * + * @return the database connection state + */ + @Override + public boolean isConnected() { + return client != null && (connection != null && connection.isOpen()); + } + + /** + * Get the bootstrap class + * instance for this database. + * + * @return the bootstrap class instance, null if none + * @see StatefulRedisConnection for bootstrap class + */ + @Override + public StatefulRedisConnection getBootstrap() { + return connection; + } + + /** + * Get the synchronized + * pipeline for this database. + * + * @return the synchronized pipeline + * @see RedisPipeline for synchronized pipeline + */ + @Override @NonNull + public RedisCommands sync() { + return connection.sync(); + } + + /** + * Get the asynchronous + * pipeline for this database. + * + * @return the asynchronous pipeline + * @see RedisPipeline for asynchronous pipeline + */ + @Override @NonNull + public RedisAsyncCommands async() { + return connection.async(); + } + + /** + * Write the given object to the database. + *

+ * This object is an instance of a class + * annotated with {@link Collection}, and + * contains fields annotated with {@link Field}. + *

+ * + * @param element the element to write + */ + @Override + public void write(@NonNull Object element) { + if (!element.getClass().isAnnotationPresent(Collection.class)) { // Missing annotation + throw new IllegalStateException("Element is missing @Collection annotation"); + } + Document document = new Document<>(element, false); // Construct the document from the element + sync().hmset(String.valueOf(document.getKey()), document.getMappedData()); // Set the map in the database + } + + /** + * Closes this stream and releases any system resources associated + * with it. If the stream is already closed then invoking this + * method has no effect. + */ + @Override + public void close() { + if (client != null) { + client.close(); + } + if (connection != null) { + connection.close(); + } + client = null; + connection = null; + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/feather/databases/redis/RedisPipeline.java b/src/main/java/me/braydon/feather/databases/redis/RedisPipeline.java new file mode 100644 index 0000000..32c654c --- /dev/null +++ b/src/main/java/me/braydon/feather/databases/redis/RedisPipeline.java @@ -0,0 +1,20 @@ +package me.braydon.feather.databases.redis; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NonNull; + +/** + * The pipeline for handling {@link Redis} operations. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public final class RedisPipeline { + /** + * The database to handle operations for. + * + * @see Redis for database + */ + @NonNull private final Redis database; +} \ No newline at end of file diff --git a/src/main/java/me/braydon/feather/repository/Repository.java b/src/main/java/me/braydon/feather/repository/Repository.java new file mode 100644 index 0000000..f757fd7 --- /dev/null +++ b/src/main/java/me/braydon/feather/repository/Repository.java @@ -0,0 +1,23 @@ +package me.braydon.feather.repository; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import me.braydon.feather.IDatabase; + +/** + * A repository belonging to a {@link IDatabase}. + * + * @author Braydon + * @param the database + */ +@AllArgsConstructor @Getter(AccessLevel.PROTECTED) +public abstract class Repository> { + /** + * The database this repository belongs to. + * + * @see D for database + */ + @NonNull private final D database; +} \ No newline at end of file