feat(database): Add Redis support

This commit is contained in:
Braydon 2023-12-12 19:03:14 -05:00
parent c9453cb1b3
commit 5e7f42b76e
13 changed files with 432 additions and 3 deletions

12
pom.xml

@ -182,6 +182,12 @@
<version>32.1.3-jre</version> <version>32.1.3-jre</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
<scope>compile</scope>
</dependency>
<!-- Databases --> <!-- Databases -->
<dependency> <dependency>
@ -190,5 +196,11 @@
<version>4.11.1</version> <version>4.11.1</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.3.0.RELEASE</version>
<scope>compile</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

@ -1,5 +1,7 @@
package me.braydon.feather; package me.braydon.feather;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@ -13,4 +15,11 @@ public final class FeatherSettings {
* The amount of threads to use for {@link FeatherThreads}. * The amount of threads to use for {@link FeatherThreads}.
*/ */
@Setter @Getter private static int threadCount = 4; @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();
} }

@ -15,6 +15,6 @@ import java.util.concurrent.atomic.AtomicInteger;
public final class FeatherThreads { public final class FeatherThreads {
private static final AtomicInteger ID = new AtomicInteger(0); // The thread id private static final AtomicInteger ID = new AtomicInteger(0); // The thread id
public static final ExecutorService THREAD_POOL = Executors.newFixedThreadPool(FeatherSettings.getThreadCount(), new ThreadFactoryBuilder() 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 .build()); // The thread pool to execute on
} }

@ -1,6 +1,8 @@
package me.braydon.feather; package me.braydon.feather;
import lombok.NonNull; import lombok.NonNull;
import me.braydon.feather.annotation.Collection;
import me.braydon.feather.annotation.Field;
import java.io.Closeable; import java.io.Closeable;
@ -61,4 +63,16 @@ public interface IDatabase<B, C, S, A> extends Closeable {
* @see A for asynchronous pipeline * @see A for asynchronous pipeline
*/ */
@NonNull A async(); @NonNull A async();
/**
* Write the given object to the database.
* <p>
* This object is an instance of a class
* annotated with {@link Collection}, and
* contains fields annotated with {@link Field}.
* </p>
*
* @param element the element to write
*/
void write(@NonNull Object element);
} }

@ -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 "";
}

@ -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.
* <p>
* If empty, the field name
* will be used as the key.
* </p>
*
* @return the key
*/
String key() default "";
}

@ -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";
}

@ -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 <V> the type of value this document holds
*/
@Getter @ToString
public class Document<V> {
/**
* 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<String, V> 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;
}
}

@ -3,10 +3,16 @@ package me.braydon.feather.databases.mongodb;
import com.mongodb.ConnectionString; import com.mongodb.ConnectionString;
import com.mongodb.client.MongoClient; import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients; import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase; import com.mongodb.client.MongoDatabase;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.UpdateOptions;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import me.braydon.feather.IDatabase; 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. * The {@link IDatabase} implementation for MongoDB.
@ -16,7 +22,7 @@ import me.braydon.feather.IDatabase;
* @see ConnectionString for the credentials class * @see ConnectionString for the credentials class
* @see MongoSyncPipeline for the sync pipeline class * @see MongoSyncPipeline for the sync pipeline class
* @see MongoAsyncPipeline for the async pipeline class * @see MongoAsyncPipeline for the async pipeline class
* @see <a href="https://www.mongodb.com/">MongoDB Official Site</a> * @see <a href="https://www.mongodb.com">MongoDB Official Site</a>
*/ */
public class MongoDB implements IDatabase<MongoClient, ConnectionString, MongoSyncPipeline, MongoAsyncPipeline> { public class MongoDB implements IDatabase<MongoClient, ConnectionString, MongoSyncPipeline, MongoAsyncPipeline> {
/** /**
@ -109,6 +115,38 @@ public class MongoDB implements IDatabase<MongoClient, ConnectionString, MongoSy
return new MongoAsyncPipeline(this); return new MongoAsyncPipeline(this);
} }
/**
* Write the given object to the database.
* <p>
* This object is an instance of a class
* annotated with {@link Collection}, and
* contains fields annotated with {@link Field}.
* </p>
*
* @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<org.bson.Document> collection = database.getCollection(collectionName); // Get the collection
Document<Object> 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 * Closes this stream and releases any system resources associated
* with it. If the stream is already closed then invoking this * with it. If the stream is already closed then invoking this

@ -6,7 +6,7 @@ import lombok.AllArgsConstructor;
import lombok.NonNull; import lombok.NonNull;
/** /**
* The pipeline for handling {@link MongoDB} operations. * The pipeline for handling synchronous {@link MongoDB} operations.
* *
* @author Braydon * @author Braydon
*/ */
@ -14,6 +14,8 @@ import lombok.NonNull;
public final class MongoSyncPipeline { public final class MongoSyncPipeline {
/** /**
* The database to handle operations for. * The database to handle operations for.
*
* @see MongoDB for database
*/ */
@NonNull private final MongoDB database; @NonNull private final MongoDB database;

@ -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 <a href="https://redis.io">Redis Official Site</a>
*/
public class Redis implements IDatabase<StatefulRedisConnection<String, String>, RedisURI, RedisCommands<String, String>, RedisAsyncCommands<String, String>> {
/**
* The current {@link RedisClient} instance.
*/
private RedisClient client;
/**
* The current established {@link StatefulRedisConnection}.
*/
private StatefulRedisConnection<String, String> 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<String, String> getBootstrap() {
return connection;
}
/**
* Get the synchronized
* pipeline for this database.
*
* @return the synchronized pipeline
* @see RedisPipeline for synchronized pipeline
*/
@Override @NonNull
public RedisCommands<String, String> sync() {
return connection.sync();
}
/**
* Get the asynchronous
* pipeline for this database.
*
* @return the asynchronous pipeline
* @see RedisPipeline for asynchronous pipeline
*/
@Override @NonNull
public RedisAsyncCommands<String, String> async() {
return connection.async();
}
/**
* Write the given object to the database.
* <p>
* This object is an instance of a class
* annotated with {@link Collection}, and
* contains fields annotated with {@link Field}.
* </p>
*
* @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<String> 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;
}
}

@ -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;
}

@ -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 <D> the database
*/
@AllArgsConstructor @Getter(AccessLevel.PROTECTED)
public abstract class Repository<D extends IDatabase<?, ?, ?, ?>> {
/**
* The database this repository belongs to.
*
* @see D for database
*/
@NonNull private final D database;
}