From bfae7c98022ee0cc467114c4d3450b3bee3fccf9 Mon Sep 17 00:00:00 2001 From: jbb01 <32650546+jbb01@users.noreply.github.com> Date: Mon, 25 Mar 2024 19:53:35 +0100 Subject: [PATCH] add database support --- bot-database/build.gradle.kts | 14 + .../chat/bot/database/BindException.java | 9 + .../chat/bot/database/Database.java | 310 ++++++++++++++++++ .../jonahbauer/chat/bot/database/Entity.java | 12 + .../database/NonUniqueResultException.java | 9 + .../chat/bot/database/annotations/Column.java | 10 + .../chat/bot/database/annotations/Table.java | 10 + .../bot/database/impl/AbstractFactory.java | 19 ++ .../chat/bot/database/impl/Binder.java | 19 ++ .../chat/bot/database/impl/BinderFactory.java | 67 ++++ .../chat/bot/database/impl/Helper.java | 173 ++++++++++ .../chat/bot/database/impl/Inserter.java | 15 + .../bot/database/impl/InserterFactory.java | 39 +++ .../chat/bot/database/impl/Preparer.java | 23 ++ .../bot/database/impl/PreparerFactory.java | 74 +++++ .../chat/bot/database/impl/Updater.java | 16 + .../bot/database/impl/UpdaterFactory.java | 45 +++ .../chat/bot/database/impl/WithId.java | 8 + .../database/impl/types/EnumTypeAdapter.java | 28 ++ .../impl/types/InstantTypeAdapter.java | 31 ++ .../database/impl/types/JdbcTypeAdapter.java | 79 +++++ .../bot/database/impl/types/TypeAdapter.java | 36 ++ bot-database/src/main/java/module-info.java | 12 + .../chat/bot/database/DatabaseTest.java | 155 +++++++++ .../chat/bot/database/EntityTest.java | 17 + gradle/libs.versions.toml | 4 + settings.gradle.kts | 1 + 27 files changed, 1235 insertions(+) create mode 100644 bot-database/build.gradle.kts create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/BindException.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/Database.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/Entity.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/NonUniqueResultException.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/annotations/Column.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/annotations/Table.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/AbstractFactory.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Binder.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/BinderFactory.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Helper.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Inserter.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/InserterFactory.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Preparer.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/PreparerFactory.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Updater.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/UpdaterFactory.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/WithId.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/EnumTypeAdapter.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/InstantTypeAdapter.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/JdbcTypeAdapter.java create mode 100644 bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/TypeAdapter.java create mode 100644 bot-database/src/main/java/module-info.java create mode 100644 bot-database/src/test/java/eu/jonahbauer/chat/bot/database/DatabaseTest.java create mode 100644 bot-database/src/test/java/eu/jonahbauer/chat/bot/database/EntityTest.java diff --git a/bot-database/build.gradle.kts b/bot-database/build.gradle.kts new file mode 100644 index 0000000..ee3ea09 --- /dev/null +++ b/bot-database/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("java-library") + id("chat-bot.java-conventions") +} + +group = "eu.jonahbauer.chat" +version = "0.1.0-SNAPSHOT" + +dependencies { + api(libs.annotations) + implementation(libs.sqlite) + implementation(libs.hikari) + implementation(libs.log4j2.api) +} \ No newline at end of file diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/BindException.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/BindException.java new file mode 100644 index 0000000..6a6737b --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/BindException.java @@ -0,0 +1,9 @@ +package eu.jonahbauer.chat.bot.database; + +import lombok.experimental.StandardException; + +import java.sql.SQLException; + +@StandardException +public class BindException extends SQLException { +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/Database.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/Database.java new file mode 100644 index 0000000..5b45294 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/Database.java @@ -0,0 +1,310 @@ +package eu.jonahbauer.chat.bot.database; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import eu.jonahbauer.chat.bot.database.impl.Binder; +import eu.jonahbauer.chat.bot.database.impl.Helper; +import eu.jonahbauer.chat.bot.database.impl.Inserter; +import eu.jonahbauer.chat.bot.database.impl.Updater; +import eu.jonahbauer.chat.bot.database.impl.types.TypeAdapter; +import lombok.Lombok; +import lombok.SneakyThrows; +import org.intellij.lang.annotations.Language; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@SuppressWarnings({"SqlSourceToSinkFlow", "unused"}) +public class Database implements AutoCloseable { + private static final ScopedValue CONNECTION = ScopedValue.newInstance(); + private final @NotNull HikariDataSource ds; + + public Database(@NotNull String url) { + this(url, null, null); + } + + public Database(@NotNull String url, @Nullable String user, @Nullable String password) { + var hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(url); + hikariConfig.setUsername(user); + hikariConfig.setPassword(password); + hikariConfig.addDataSourceProperty("enforceForeignKeys", true); + this.ds = new HikariDataSource(hikariConfig); + } + + + @SuppressWarnings("RedundantThrows") + public void transaction(@NotNull ThrowingRunnable runnable) throws SQLException, T { + if (CONNECTION.isBound()) throw new IllegalStateException("nested transactions are not supported"); + + try (var connection = getConnection0()) { + connection.setAutoCommit(false); + try { + ScopedValue.runWhere(CONNECTION, connection, () -> { + try { + runnable.run(); + } catch (Throwable t) { + throw Lombok.sneakyThrow(t); + } + }); + connection.commit(); + } catch (Throwable t) { + connection.rollback(); + throw t; + } + } + } + + @SuppressWarnings("RedundantThrows") + public R transaction(@NotNull ThrowingCallable callable) throws SQLException, T { + if (CONNECTION.isBound()) throw new IllegalStateException("nested transactions are not supported"); + + try (var connection = getConnection0()) { + connection.setAutoCommit(false); + try { + var result = ScopedValue.callWhere(CONNECTION, connection, () -> { + try { + return callable.call(); + } catch (Throwable t) { + throw Lombok.sneakyThrow(t); + } + }); + connection.commit(); + return result; + } catch (Throwable t) { + connection.rollback(); + throw Lombok.sneakyThrow(t); + } + } + } + + public @NotNull List findAll(@NotNull Class type) throws SQLException { + return executeQuery(type, "SELECT * FROM " + Helper.getQuotedTableName(type)); + } + + public > @NotNull Optional findById(@NotNull Class type, long id) throws SQLException { + return findWhere(type, "id", id); + } + + @SuppressWarnings("Convert2MethodRef") + public @NotNull List findAllWhere(@NotNull Class type, @NotNull String component, Object value) throws SQLException { + return findWhere0(type, component, value, (t, query, customizer) -> executeQuery(t, query, customizer)); + } + + @SuppressWarnings("Convert2MethodRef") + public @NotNull Optional findWhere(@NotNull Class type, @NotNull String component, Object value) throws SQLException { + return findWhere0(type, component, value, (t, query, customizer) -> executeUniqueQuery(t, query, customizer)); + } + + @SneakyThrows(NoSuchMethodException.class) + private R findWhere0(@NotNull Class type, @NotNull String component, Object value, @NotNull FindWhere finder) throws SQLException { + var table = Helper.getQuotedTableName(type); + var column = Helper.getColumnName(type, component); + var componentType = type.getMethod(component).getReturnType(); + var adapter = TypeAdapter.get(componentType); + return finder.find( + type, + "SELECT * FROM " + table + " WHERE " + column + " " + (value == null ? "IS NULL" : "= ?"), + stmt -> { + if (value != null) adapter.prepareUnsafe(stmt, 1, value); + } + ); + } + + private interface FindWhere { + R find(@NotNull Class type, @Language("SQL") @NotNull String query, @NotNull ThrowingConsumer customizer) throws SQLException; + } + + public @NotNull List bindAll(@NotNull Class type, @NotNull ResultSet result) throws SQLException { + return bindAll(type, result, ""); + } + + public @NotNull List bindAll(@NotNull Class type, @NotNull ResultSet result, @NotNull String prefix) throws SQLException { + var out = new ArrayList(); + var binder = Binder.get(type); + while (result.next()) { + out.add(binder.bind(result, prefix)); + } + return Collections.unmodifiableList(out); + } + + public @NotNull Optional bind(@NotNull Class type, @NotNull ResultSet result) throws SQLException { + return bind(type, result, ""); + } + + public @NotNull Optional bind(@NotNull Class type, @NotNull ResultSet result, @NotNull String prefix) throws SQLException { + if (result.next()) { + var binder = Binder.get(type); + var out = Optional.of(binder.bind(result, prefix)); + if (result.next()) throw new NonUniqueResultException(); + return out; + } else { + return Optional.empty(); + } + } + + @SuppressWarnings("unchecked") + public > boolean delete(@NotNull T entity) throws SQLException { + return delete((Class) entity.getClass(), entity.id()); + } + + public > boolean delete(@NotNull Class type, long id) throws SQLException { + return 1 == executeUpdate("DELETE FROM " + Helper.getQuotedTableName(type) + " WHERE id = ?", stmt -> stmt.setLong(1, id)); + } + + public long insert(@NotNull Record record) throws SQLException { + return insert0(record); + } + + private long insert0(@NotNull T record) throws SQLException { + @SuppressWarnings("unchecked") + var type = (Class) record.getClass(); + var connection = getConnection0(); + try { + return Inserter.get(type).insert(connection, record); + } finally { + close(connection); + } + } + + public T save(@NotNull T record) throws SQLException { + @SuppressWarnings("unchecked") + var type = (Class) record.getClass(); + var connection = getConnection0(); + try { + var id = Inserter.get(type).insert(connection, record); + if (record instanceof Entity entity) { + return (T) entity.withId(id); + } else { + return record; + } + } finally { + close(connection); + } + } + + public > boolean update(@NotNull T entity) throws SQLException { + @SuppressWarnings("unchecked") + var type = (Class) entity.getClass(); + var connection = getConnection0(); + try { + return Updater.get(type).update(connection, entity); + } finally { + close(connection); + } + } + + @SuppressWarnings("SqlWithoutWhere") + public int truncate(@NotNull Class type) throws SQLException { + return executeUpdate("DELETE FROM " + Helper.getQuotedTableName(type)); + } + + + public @NotNull List executeQuery(@NotNull Class type, @NotNull @Language("SQL") String query) throws SQLException { + var connection = getConnection0(); + try (var stmt = connection.createStatement()) { + Helper.logSqlStatement(query); + try (var result = stmt.executeQuery(query)) { + return bindAll(type, result); + } + } finally { + close(connection); + } + } + + public @NotNull List executeQuery(@NotNull Class type, @NotNull @Language("SQL") String query, @NotNull ThrowingConsumer<@NotNull PreparedStatement, T> customizer) throws SQLException, T { + var connection = getConnection0(); + Helper.logSqlStatement(query); + try (var stmt = connection.prepareStatement(query)) { + customizer.accept(stmt); + try (var result = stmt.executeQuery()) { + return bindAll(type, result); + } + } finally { + close(connection); + } + } + + public @NotNull Optional executeUniqueQuery(@NotNull Class type, @NotNull @Language("SQL") String query) throws SQLException { + var connection = getConnection0(); + try (var stmt = connection.createStatement()) { + Helper.logSqlStatement(query); + try (var result = stmt.executeQuery(query)) { + return bind(type, result); + } + } finally { + close(connection); + } + } + + public @NotNull Optional executeUniqueQuery(@NotNull Class type, @NotNull @Language("SQL") String query, @NotNull ThrowingConsumer<@NotNull PreparedStatement, T> customizer) throws SQLException, T { + var connection = getConnection0(); + Helper.logSqlStatement(query); + try (var stmt = connection.prepareStatement(query)) { + customizer.accept(stmt); + try (var result = stmt.executeQuery()) { + return bind(type, result); + } + } finally { + close(connection); + } + } + + public int executeUpdate(@NotNull @Language("SQL") String query) throws SQLException { + var connection = getConnection0(); + try (var stmt = connection.createStatement()) { + Helper.logSqlStatement(query); + return stmt.executeUpdate(query); + } finally { + close(connection); + } + } + + public int executeUpdate(@NotNull @Language("SQL") String query, @NotNull ThrowingConsumer<@NotNull PreparedStatement, T> customizer) throws SQLException, T { + var connection = getConnection0(); + Helper.logSqlStatement(query); + try (var stmt = connection.prepareStatement(query)) { + customizer.accept(stmt); + return stmt.executeUpdate(); + } finally { + close(connection); + } + } + + public @NotNull Connection getConnection() throws SQLException { + return ds.getConnection(); + } + + private @NotNull Connection getConnection0() throws SQLException { + return CONNECTION.isBound() ? CONNECTION.get() : ds.getConnection(); + } + + void close(@NotNull Connection connection) throws SQLException { + if (!CONNECTION.isBound() || CONNECTION.get() != connection) connection.close(); + } + + @Override + public void close() { + ds.close(); + } + + public interface ThrowingCallable { + R call() throws T; + } + + public interface ThrowingRunnable { + void run() throws T; + } + + public interface ThrowingConsumer { + void accept(S object) throws T; + } +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/Entity.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/Entity.java new file mode 100644 index 0000000..103407c --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/Entity.java @@ -0,0 +1,12 @@ +package eu.jonahbauer.chat.bot.database; + +import eu.jonahbauer.chat.bot.database.impl.Helper; + +public interface Entity> { + long id(); + + @SuppressWarnings("unchecked") + default T withId(long id) { + return Helper.getWithId((Class) getClass()).withId((T) this, id); + } +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/NonUniqueResultException.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/NonUniqueResultException.java new file mode 100644 index 0000000..d0d14a8 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/NonUniqueResultException.java @@ -0,0 +1,9 @@ +package eu.jonahbauer.chat.bot.database; + +import lombok.experimental.StandardException; + +import java.sql.SQLException; + +@StandardException +public class NonUniqueResultException extends SQLException { +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/annotations/Column.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/annotations/Column.java new file mode 100644 index 0000000..4f2d914 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/annotations/Column.java @@ -0,0 +1,10 @@ +package eu.jonahbauer.chat.bot.database.annotations; + +import java.lang.annotation.*; + +@Documented +@Target(ElementType.RECORD_COMPONENT) +@Retention(RetentionPolicy.RUNTIME) +public @interface Column { + String value(); +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/annotations/Table.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/annotations/Table.java new file mode 100644 index 0000000..58d3a26 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/annotations/Table.java @@ -0,0 +1,10 @@ +package eu.jonahbauer.chat.bot.database.annotations; + +import java.lang.annotation.*; + +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Table { + String value(); +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/AbstractFactory.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/AbstractFactory.java new file mode 100644 index 0000000..c2f0b1c --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/AbstractFactory.java @@ -0,0 +1,19 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import org.jetbrains.annotations.NotNull; + +abstract class AbstractFactory extends ClassValue { + + @Override + protected final @NotNull F computeValue(Class type) { + if (!type.isRecord()) throw new IllegalArgumentException(); + return bridge(type); + } + + @SuppressWarnings("unchecked") + private @NotNull F bridge(@NotNull Class type) { + return computeValue0((Class) type); + } + + protected abstract @NotNull F computeValue0(@NotNull Class type); +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Binder.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Binder.java new file mode 100644 index 0000000..40df653 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Binder.java @@ -0,0 +1,19 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import org.jetbrains.annotations.NotNull; + +import java.sql.ResultSet; +import java.sql.SQLException; + +public interface Binder { + @SuppressWarnings("unchecked") + static @NotNull Binder get(@NotNull Class type) { + return (Binder) BinderFactory.INSTANCE.get(type); + } + + default @NotNull T bind(@NotNull ResultSet set) throws SQLException { + return bind(set, ""); + } + + @NotNull T bind(@NotNull ResultSet set, @NotNull String prefix) throws SQLException; +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/BinderFactory.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/BinderFactory.java new file mode 100644 index 0000000..7c001d3 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/BinderFactory.java @@ -0,0 +1,67 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import eu.jonahbauer.chat.bot.database.BindException; +import eu.jonahbauer.chat.bot.database.impl.types.TypeAdapter; +import lombok.SneakyThrows; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Constructor; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +final class BinderFactory extends AbstractFactory> { + public static final @NotNull BinderFactory INSTANCE = new BinderFactory(); + + private BinderFactory() {} + + @Override + @SneakyThrows + protected @NotNull Binder computeValue0(@NotNull Class type) { + var components = type.getRecordComponents(); + + var columns = new ArrayList(); + var adapters = new ArrayList>(); + var types = new ArrayList>(); + + for (var component : components) { + var componentType = component.getType(); + types.add(componentType); + columns.add(Helper.getColumnName(component)); + adapters.add(TypeAdapter.get(componentType)); + } + + var ctor = type.getDeclaredConstructor(types.toArray(Class[]::new)); + return new BinderImpl<>(ctor, columns, adapters); + } + + private static final class BinderImpl implements Binder { + private final @NotNull Constructor constructor; + private final @NotNull List<@NotNull String> columns; + private final @NotNull List<@NotNull TypeAdapter> adapters; + + private BinderImpl(@NotNull Constructor constructor, @NotNull List<@NotNull String> columns, @NotNull List<@NotNull TypeAdapter> adapters) { + this.constructor = Objects.requireNonNull(constructor); + this.columns = List.copyOf(columns); + this.adapters = List.copyOf(adapters); + if (this.columns.size() != this.adapters.size()) throw new IllegalArgumentException(); + } + + @Override + public @NotNull T bind(@NotNull ResultSet result, @NotNull String prefix) throws SQLException { + try { + var count = columns.size(); + var arguments = new Object[count]; + for (int i = 0; i < count; i++) { + var index = result.findColumn(prefix + columns.get(i)); + arguments[i] = adapters.get(i).extract(result, index); + } + return constructor.newInstance(arguments); + } catch (Exception ex) { + throw new BindException(ex); + } + } + } +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Helper.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Helper.java new file mode 100644 index 0000000..0c8c287 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Helper.java @@ -0,0 +1,173 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import eu.jonahbauer.chat.bot.database.Entity; +import eu.jonahbauer.chat.bot.database.annotations.Column; +import eu.jonahbauer.chat.bot.database.annotations.Table; +import lombok.Lombok; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.RecordComponent; +import java.util.HashMap; +import java.util.Map; + +import static java.lang.invoke.MethodHandles.*; +import static java.lang.invoke.MethodType.methodType; + +@UtilityClass +public class Helper { + public static @NotNull String getQuotedColumnName(@NotNull RecordComponent component) { + return quote(getColumnName(component)); + } + public static @NotNull String getColumnName(@NotNull RecordComponent component) { + return getColumnName(component.getDeclaringRecord(), component.getName()); + } + public static @NotNull String getColumnName(@NotNull Class type, @NotNull String component) { + var out = columnNameCache.get(type).get(component); + if (out == null) throw new IllegalArgumentException("invalid component name " + component); + return out; + } + private static final @NotNull ClassValue<@NotNull Map<@NotNull String, @NotNull String>> columnNameCache = new ClassValue<>() { + @Override + protected @NotNull Map<@NotNull String, @NotNull String> computeValue(@NotNull Class type) { + var out = new HashMap(); + + for (var component : type.getRecordComponents()) { + var column = component.getAnnotation(Column.class); + if (column != null) { + out.put(component.getName(), column.value()); + } else { + out.put(component.getName(), getDatabaseName(component.getName())); + } + } + + return Map.copyOf(out); + } + }; + + public static @NotNull String getQuotedTableName(@NotNull Class type) { + return tableNameCache.get(type); + } + private static final @NotNull ClassValue<@NotNull String> tableNameCache = new ClassValue<>() { + @Override + protected @NotNull String computeValue(@NotNull Class type) { + var table = type.getAnnotation(Table.class); + if (table != null) { + return quote(table.value()); + } else { + return quote(getDatabaseName(type.getSimpleName())); + } + } + }; + + public static boolean isIdColumn(@NotNull RecordComponent component) { + return Entity.class.isAssignableFrom(component.getDeclaringRecord()) + && component.getType() == long.class + && "id".equals(component.getName()); + } + + @SuppressWarnings("unchecked") + public static > WithId getWithId(@NotNull Class type) { + return (WithId) withIdCache.get(type); + } + private static final @NotNull ClassValue<@NotNull WithId> withIdCache = new ClassValue<>() { + @Override + @SneakyThrows + protected WithId computeValue(Class type) { + if (!type.isRecord()) throw new IllegalArgumentException(); + if (!Entity.class.isAssignableFrom(type)) throw new IllegalArgumentException(); + + var lookup = MethodHandles.lookup(); + + var components = type.getRecordComponents(); + var types = new Class[components.length]; + var accessors = new MethodHandle[components.length]; + var reorder = new int[components.length]; + + for (int i = 0; i < components.length; i++) { + var component = components[i]; + types[i] = component.getType(); + if (isIdColumn(component)) { + accessors[i] = identity(long.class); + reorder[i] = 1; + } else { + accessors[i] = lookup.unreflect(component.getAccessor()); + reorder[i] = 0; + } + } + + var ctor = lookup.findConstructor(type, methodType(void.class, types)); + var cloner = filterArguments(ctor, 0, accessors); + var wither = permuteArguments(cloner, methodType(type, type, long.class), reorder); + + return create(wither.asType(methodType(Record.class, Record.class, long.class))); + } + + @SuppressWarnings("unchecked") + private > WithId create(@NotNull MethodHandle handle) { + return (entity, id) -> { + try { + return (T) handle.invokeExact(entity, id); + } catch (Throwable t) { + throw Lombok.sneakyThrow(t); + } + }; + } + }; + + + private static final @NotNull Logger log = LogManager.getLogger("eu.jonahbauer.chat.bot.database.SQL"); + public static void logSqlStatement(@NotNull String sql) { + log.debug(sql); + } + + private static @NotNull String getDatabaseName(@NotNull String name) { + if (name.length() <= 1) return name; + + StringBuilder out = null; + for (int i = 1, length = name.length(); i < length; i++) { + var prev = name.charAt(i - 1); + var curr = name.charAt(i); + if (Character.isLowerCase(prev) && Character.isUpperCase(curr)) { + if (out == null) { + out = new StringBuilder(name.length() + 1); + out.append(name, 0, i); + out.setCharAt(0, Character.toLowerCase(out.charAt(0))); + } + out.append('_'); + out.append(Character.toLowerCase(curr)); + } else if (out != null) { + out.append(curr); + } + } + + if (out == null) { + if (Character.isUpperCase(name.charAt(0))) { + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } else { + return name; + } + } else { + return out.toString(); + } + } + + public static @NotNull String quote(@NotNull String name) { + var out = new StringBuilder(name.length() + 2); + out.append('"'); + for (int i = 0, length = name.length(); i < length; i++) { + var chr = name.charAt(i); + if (chr == '"') out.append('"'); + if (chr == '\n') out.append('\\'); + + out.append(chr); + } + out.append('"'); + return out.toString(); + } +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Inserter.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Inserter.java new file mode 100644 index 0000000..78f5fb1 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Inserter.java @@ -0,0 +1,15 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.SQLException; + +public interface Inserter { + @SuppressWarnings("unchecked") + static @NotNull Inserter get(@NotNull Class type) { + return (Inserter) InserterFactory.INSTANCE.get(type); + } + + long insert(@NotNull Connection connection, @NotNull T entity) throws SQLException; +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/InserterFactory.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/InserterFactory.java new file mode 100644 index 0000000..d7ba5b4 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/InserterFactory.java @@ -0,0 +1,39 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import eu.jonahbauer.chat.bot.database.Entity; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Collections; + +final class InserterFactory extends AbstractFactory<@NotNull Inserter> { + public static final @NotNull InserterFactory INSTANCE = new InserterFactory(); + + private InserterFactory() {} + + @Override + protected @NotNull Inserter computeValue0(@NotNull Class type) { + var isEntity = Entity.class.isAssignableFrom(type); + var table = Helper.getQuotedTableName(type); + var preparer = Preparer.get(type); + var columns = preparer.columns(); + + var query = STR.""" + INSERT INTO \{table} (\{String.join(", ", columns)}) + VALUES (\{String.join(", ", Collections.nCopies(columns.size(), "?"))}) + \{isEntity ? "RETURNING id" : "RETURNING -1"} + """; + + return (Connection connection, T entity) -> { + Helper.logSqlStatement(query); + try (var stmt = connection.prepareStatement(query)) { + preparer.prepare(stmt, entity); + try (var result = stmt.executeQuery()) { + if (!result.next()) throw new SQLException(); + return result.getLong(1); + } + } + }; + } +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Preparer.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Preparer.java new file mode 100644 index 0000000..46b0508 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Preparer.java @@ -0,0 +1,23 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import org.jetbrains.annotations.NotNull; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.List; + +public interface Preparer { + + @SuppressWarnings("unchecked") + static @NotNull Preparer get(@NotNull Class type) { + return (Preparer) PreparerFactory.INSTANCE.get(type); + } + + default void prepare(@NotNull PreparedStatement statement, @NotNull T entity) throws SQLException { + prepare(statement, entity, 1); + } + + void prepare(@NotNull PreparedStatement statement, @NotNull T entity, int index) throws SQLException; + + @NotNull List<@NotNull String> columns(); +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/PreparerFactory.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/PreparerFactory.java new file mode 100644 index 0000000..ebc085c --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/PreparerFactory.java @@ -0,0 +1,74 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import eu.jonahbauer.chat.bot.database.impl.types.TypeAdapter; +import lombok.Lombok; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Method; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +final class PreparerFactory extends AbstractFactory> { + public static final @NotNull PreparerFactory INSTANCE = new PreparerFactory(); + + private PreparerFactory() {} + + @Override + protected @NotNull Preparer computeValue0(@NotNull Class type) { + var components = type.getRecordComponents(); + + var columns = new ArrayList(components.length - 1); + var accessors = new ArrayList(components.length - 1); + var adapters = new ArrayList>(components.length - 1); + + for (var component : components) { + if (Helper.isIdColumn(component)) continue; + + columns.add(Helper.getQuotedColumnName(component)); + accessors.add(component.getAccessor()); + adapters.add(TypeAdapter.get(component.getType())); + } + + return new PreparerImpl<>(columns, adapters, accessors); + } + + private static final class PreparerImpl implements Preparer { + private final @NotNull List<@NotNull String> columns; + private final @NotNull List<@NotNull TypeAdapter> adapters; + private final @NotNull List<@NotNull Method> accessors; + + public PreparerImpl( + @NotNull List<@NotNull String> columns, + @NotNull List<@NotNull TypeAdapter> adapters, + @NotNull List<@NotNull Method> accessors + ) { + this.columns = List.copyOf(columns); + this.adapters = List.copyOf(adapters); + this.accessors = List.copyOf(accessors); + if (this.columns.size() != this.adapters.size() || this.adapters.size() != this.accessors.size()) { + throw new IllegalArgumentException(); + } + } + + @Override + public void prepare(@NotNull PreparedStatement stmt, @NotNull T entity, int offset) throws SQLException { + try { + for (int i = 0, length = columns.size(); i < length; i++) { + var value = accessors.get(i).invoke(entity); + adapters.get(i).prepareUnsafe(stmt, i + offset, value); + } + } catch (SQLException ex) { + throw ex; + } catch (Exception ex) { + throw Lombok.sneakyThrow(ex); + } + } + + @Override + public @NotNull List<@NotNull String> columns() { + return columns; + } + } +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Updater.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Updater.java new file mode 100644 index 0000000..5b8cd06 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/Updater.java @@ -0,0 +1,16 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import eu.jonahbauer.chat.bot.database.Entity; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.SQLException; + +public interface Updater { + @SuppressWarnings("unchecked") + static @NotNull Updater get(@NotNull Class type) { + return (Updater) UpdaterFactory.INSTANCE.get(type); + } + + boolean update(@NotNull Connection connection, @NotNull T entity) throws SQLException; +} \ No newline at end of file diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/UpdaterFactory.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/UpdaterFactory.java new file mode 100644 index 0000000..373b75f --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/UpdaterFactory.java @@ -0,0 +1,45 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import eu.jonahbauer.chat.bot.database.Entity; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.util.stream.Collectors; + +final class UpdaterFactory extends AbstractFactory<@NotNull Updater> { + public static final @NotNull UpdaterFactory INSTANCE = new UpdaterFactory(); + + private UpdaterFactory() {} + + @Override + protected @NotNull Updater computeValue0(@NotNull Class type) { + if (!Entity.class.isAssignableFrom(type)) throw new IllegalArgumentException(); + return bridge(type); + } + + @SuppressWarnings("unchecked") + private @NotNull Updater bridge(@NotNull Class type) { + return computeValue1((Class) type); + } + + private @NotNull Updater computeValue1(@NotNull Class type) { + var table = Helper.getQuotedTableName(type); + var preparer = Preparer.get(type); + var columns = preparer.columns(); + + var query = STR.""" + UPDATE \{table} + SET \{columns.stream().map(col -> col + " = ?").collect(Collectors.joining(", "))} + WHERE id = ? + """; + + return (Connection connection, T entity) -> { + Helper.logSqlStatement(query); + try (var stmt = connection.prepareStatement(query)) { + preparer.prepare(stmt, entity); + stmt.setLong(columns.size() + 1, entity.id()); + return stmt.executeUpdate() == 1; + } + }; + } +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/WithId.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/WithId.java new file mode 100644 index 0000000..3fe4b4a --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/WithId.java @@ -0,0 +1,8 @@ +package eu.jonahbauer.chat.bot.database.impl; + +import eu.jonahbauer.chat.bot.database.Entity; + +@FunctionalInterface +public interface WithId> { + T withId(T entity, long id); +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/EnumTypeAdapter.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/EnumTypeAdapter.java new file mode 100644 index 0000000..a760a89 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/EnumTypeAdapter.java @@ -0,0 +1,28 @@ +package eu.jonahbauer.chat.bot.database.impl.types; + +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; + +@RequiredArgsConstructor +public class EnumTypeAdapter> implements TypeAdapter { + private final @NotNull Class type; + + @Override + public void prepare(@NotNull PreparedStatement stmt, int index, E value) throws SQLException { + if (value == null) { + stmt.setNull(index, Types.VARCHAR); + } else { + stmt.setString(index, value.name()); + } + } + + @Override + public E extract(@NotNull ResultSet result, int index) throws SQLException { + return Enum.valueOf(type, result.getString(index)); + } +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/InstantTypeAdapter.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/InstantTypeAdapter.java new file mode 100644 index 0000000..8b4ffbd --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/InstantTypeAdapter.java @@ -0,0 +1,31 @@ +package eu.jonahbauer.chat.bot.database.impl.types; + +import org.jetbrains.annotations.NotNull; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.time.Instant; + +public class InstantTypeAdapter implements TypeAdapter { + + @Override + public void prepare(@NotNull PreparedStatement stmt, int index, Instant value) throws SQLException { + if (value == null) { + stmt.setNull(index, Types.INTEGER); + } else { + stmt.setLong(index, value.toEpochMilli()); + } + } + + @Override + public Instant extract(@NotNull ResultSet result, int index) throws SQLException { + var millis = result.getLong(index); + if (result.wasNull()) { + return null; + } else { + return Instant.ofEpochMilli(millis); + } + } +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/JdbcTypeAdapter.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/JdbcTypeAdapter.java new file mode 100644 index 0000000..88ab787 --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/JdbcTypeAdapter.java @@ -0,0 +1,79 @@ +package eu.jonahbauer.chat.bot.database.impl.types; + +import org.jetbrains.annotations.NotNull; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; + +public class JdbcTypeAdapter implements TypeAdapter { + private final @NotNull Class type; + + JdbcTypeAdapter(@NotNull Class type) { + this.type = type; + } + + @Override + public void prepare(@NotNull PreparedStatement stmt, int index, T value) throws SQLException { + if (type == byte.class || type == Byte.class) { + if (value == null) stmt.setNull(index, Types.INTEGER); + else stmt.setByte(index, (byte) value); + } else if (type == char.class || type == Character.class) { + if (value == null) stmt.setNull(index, Types.INTEGER); + else stmt.setShort(index, (short) (char) value); + } else if (type == short.class || type == Short.class) { + if (value == null) stmt.setNull(index, Types.INTEGER); + else stmt.setShort(index, (short) value); + } else if (type == int.class || type == Integer.class) { + if (value == null) stmt.setNull(index, Types.INTEGER); + else stmt.setInt(index, (int) value); + } else if (type == long.class || type == Long.class) { + if (value == null) stmt.setNull(index, Types.INTEGER); + else stmt.setLong(index, (long) value); + } else if (type == float.class || type == Float.class) { + if (value == null) stmt.setNull(index, Types.FLOAT); + else stmt.setFloat(index, (float) value); + } else if (type == double.class || type == Double.class) { + if (value == null) stmt.setNull(index, Types.DOUBLE); + else stmt.setDouble(index, (double) value); + } else if (type == boolean.class || type == Boolean.class) { + if (value == null) stmt.setNull(index, Types.BOOLEAN); + else stmt.setBoolean(index, (boolean) value); + } else { + stmt.setObject(index, value); + } + } + + @Override + @SuppressWarnings("unchecked") + public T extract(@NotNull ResultSet result, int index) throws SQLException { + if (type == byte.class || type == Byte.class) { + var out = (T) (Byte) result.getByte(index); + return result.wasNull() ? null : out; + } else if (type == char.class || type == Character.class) { + var out = (T) (Character) (char) result.getShort(index); + return result.wasNull() ? null : out; + } else if (type == short.class || type == Short.class) { + var out = (T) (Short) result.getShort(index); + return result.wasNull() ? null : out; + } else if (type == int.class || type == Integer.class) { + var out = (T) (Integer) result.getInt(index); + return result.wasNull() ? null : out; + } else if (type == long.class || type == Long.class) { + var out = (T) (Long) result.getLong(index); + return result.wasNull() ? null : out; + } else if (type == float.class || type == Float.class) { + var out = (T) (Float) result.getFloat(index); + return result.wasNull() ? null : out; + } else if (type == double.class || type == Double.class) { + var out = (T) (Double) result.getDouble(index); + return result.wasNull() ? null : out; + } else if (type == boolean.class || type == Boolean.class) { + var out = (T) (Boolean) result.getBoolean(index); + return result.wasNull() ? null : out; + } else { + return result.getObject(index, type); + } + } +} diff --git a/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/TypeAdapter.java b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/TypeAdapter.java new file mode 100644 index 0000000..d6e2f5d --- /dev/null +++ b/bot-database/src/main/java/eu/jonahbauer/chat/bot/database/impl/types/TypeAdapter.java @@ -0,0 +1,36 @@ +package eu.jonahbauer.chat.bot.database.impl.types; + +import org.jetbrains.annotations.NotNull; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; + +public interface TypeAdapter { + @SuppressWarnings("unchecked") + static @NotNull TypeAdapter get(@NotNull Class type) { + if (type == Instant.class) { + return (TypeAdapter) new InstantTypeAdapter(); + } else if (Enum.class.isAssignableFrom(type)) { + return (TypeAdapter) newEnumTypeAdapter(type); + } else { + return new JdbcTypeAdapter<>(type); + } + } + + @SuppressWarnings("unchecked") + private static > EnumTypeAdapter newEnumTypeAdapter(Class type) { + return new EnumTypeAdapter<>((Class) type); + } + + @SuppressWarnings("unchecked") + default void prepareUnsafe(@NotNull PreparedStatement stmt, int index, Object value) throws SQLException { + prepare(stmt, index, (T) value); + } + + void prepare(@NotNull PreparedStatement stmt, int index, T value) throws SQLException; + + T extract(@NotNull ResultSet result, int index) throws SQLException; + +} diff --git a/bot-database/src/main/java/module-info.java b/bot-database/src/main/java/module-info.java new file mode 100644 index 0000000..5bd2607 --- /dev/null +++ b/bot-database/src/main/java/module-info.java @@ -0,0 +1,12 @@ +module eu.jonahbauer.chat.bot.database { + exports eu.jonahbauer.chat.bot.database; + exports eu.jonahbauer.chat.bot.database.annotations; + + requires transitive java.sql; + requires org.xerial.sqlitejdbc; + requires com.zaxxer.hikari; + requires org.apache.logging.log4j; + + requires static lombok; + requires static transitive org.jetbrains.annotations; +} \ No newline at end of file diff --git a/bot-database/src/test/java/eu/jonahbauer/chat/bot/database/DatabaseTest.java b/bot-database/src/test/java/eu/jonahbauer/chat/bot/database/DatabaseTest.java new file mode 100644 index 0000000..1c83b11 --- /dev/null +++ b/bot-database/src/test/java/eu/jonahbauer/chat/bot/database/DatabaseTest.java @@ -0,0 +1,155 @@ +package eu.jonahbauer.chat.bot.database; + +import eu.jonahbauer.chat.bot.database.annotations.Column; +import eu.jonahbauer.chat.bot.database.annotations.Table; +import org.intellij.lang.annotations.Language; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +public class DatabaseTest { + + @TempDir + static Path temp; + + Database database; + + @BeforeEach + void init() throws IOException, SQLException { + var file = Files.createTempFile(temp, "database", ".db"); + var url = "jdbc:sqlite:" + file.toAbsolutePath(); + database = new Database(url); + database.executeUpdate(""" + CREATE TABLE "pizza" + ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "restaurant_id" INTEGER NOT NULL, + "number" INTEGER NOT NULL, + "name" VARCHAR(255) NOT NULL, + "ingredients" VARCHAR(255) NOT NULL, + "price" DOUBLE NOT NULL, + "test" INTEGER + ) + """); + } + + @Test + void crud() throws SQLException { + crud(Pizza.class, Pizza::new); + } + + @Test + void annotations() throws SQLException { + crud(AnnotatedPizza.class, AnnotatedPizza::new); + } + + @Test + void transaction() throws SQLException { + assertThrowsExactly(RuntimeException.class, () -> { + database.transaction(() -> { + assertThrows(IllegalStateException.class, () -> database.transaction(() -> {})); + + database.insert(new Pizza(-1, 1, 10, "Salami", "Salami, Tomaten, Brot", 12.5)); + assertEquals(1, database.findAll(Pizza.class).size()); + throw new RuntimeException(); + }); + }); + assertEquals(List.of(), database.findAll(Pizza.class)); + } + + @Test + void find() throws SQLException { + var salami = new Pizza(1, 1, 10, "Salami", "Salami, Tomaten, Brot", 12.5); + var speziale = new Pizza(2, 1, 15, "Speziale", "Salami, Schinken, Pilze, Tomaten, Brot", 12.5); + + assertEquals(1, database.insert(salami)); + assertEquals(2, database.insert(speziale)); + + assertEquals(Optional.empty(), database.findWhere(Pizza.class, "name", "Hawaii")); + assertEquals(Optional.of(salami), database.findWhere(Pizza.class, "name", "Salami")); + assertEquals(Optional.of(speziale), database.findWhere(Pizza.class, "name", "Speziale")); + assertThrows(NonUniqueResultException.class, () -> database.findWhere(Pizza.class, "restaurantId", 1L)); + assertEquals(List.of(salami, speziale), database.findAllWhere(Pizza.class, "restaurantId", 1L)); + } + + @Test + void join() throws SQLException { + var salami = new Pizza(1, 1, 10, "Salami", "Salami, Tomaten, Brot", 12.5); + var speziale = new Pizza(2, 1, 15, "Speziale", "Salami, Schinken, Pilze, Tomaten, Brot", 12.5); + + assertEquals(1, database.insert(salami)); + assertEquals(2, database.insert(speziale)); + + @Language("SQL") + var query = """ + SELECT "pizza".* + FROM "pizza" + JOIN "pizza" "p2" ON "pizza"."restaurant_id" = "p2"."restaurant_id" + """; + assertEquals(List.of(salami, salami, speziale, speziale), database.executeQuery(Pizza.class, query)); + + @Language("SQL") + var query2 = """ + SELECT "p2".* + FROM "pizza" + JOIN "pizza" "p2" ON "pizza"."restaurant_id" = "p2"."restaurant_id" + """; + assertEquals(List.of(salami, speziale, salami, speziale), database.executeQuery(Pizza.class, query2)); + } + + private > void crud(@NotNull Class type, @NotNull PizzaBaker baker) throws SQLException { + var salami = baker.bake(-1, 1, 10, "Salami", "Salami, Tomaten, Brot", 12.5); + var persistent = baker.bake(1, 1, 10, "Salami", "Salami, Tomaten, Brot", 12.5); + var speziale = baker.bake(1, 1, 15, "Speziale", "Salami, Schinken, Pilze, Tomaten, Brot", 12.5); + + // create + assertEquals(1, database.insert(salami)); + assertEquals(List.of(persistent), database.findAll(type)); + assertEquals(Optional.of(persistent), database.findById(type, 1)); + assertEquals(Optional.empty(), database.findById(type, 2)); + + // update + assertFalse(database.update(salami)); + assertTrue(database.update(speziale)); + assertEquals(List.of(speziale), database.findAll(type)); + assertEquals(Optional.of(speziale), database.findById(type, 1)); + assertEquals(Optional.empty(), database.findById(type, 2)); + + // delete + assertFalse(database.delete(type, 2)); + assertTrue(database.delete(type, 1)); + assertEquals(List.of(), database.findAll(type)); + + // truncate + database.insert(salami); + database.insert(speziale); + assertEquals(2, database.findAll(type).size()); + assertEquals(2, database.truncate(type)); + assertEquals(List.of(), database.findAll(type)); + } + + @AfterEach + void cleanup() { + database.close(); + } + + private interface PizzaBaker> { + @NotNull T bake(long id, long restaurantId, int number, @NotNull String name, @NotNull String ingredients, double price); + } + + public record Pizza(long id, long restaurantId, int number, @NotNull String name, @NotNull String ingredients, double price) implements Entity {} + + @Table("pizza") + public record AnnotatedPizza(long id, @Column("restaurant_id") long restaurant, int number, @NotNull String name, @NotNull String ingredients, double price) implements Entity {} +} diff --git a/bot-database/src/test/java/eu/jonahbauer/chat/bot/database/EntityTest.java b/bot-database/src/test/java/eu/jonahbauer/chat/bot/database/EntityTest.java new file mode 100644 index 0000000..1d43376 --- /dev/null +++ b/bot-database/src/test/java/eu/jonahbauer/chat/bot/database/EntityTest.java @@ -0,0 +1,17 @@ +package eu.jonahbauer.chat.bot.database; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class EntityTest { + + public record TestEntity(long id, String foo, String bar) implements Entity {} + public record TestEntity2(String foo, String bar, long id) implements Entity {} + + @Test + void withId() { + assertEquals(new TestEntity(1, "foo", "bar"), new TestEntity(0, "foo", "bar").withId(1)); + assertEquals(new TestEntity2("foo", "bar", 1), new TestEntity2("foo", "bar", 0).withId(1)); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0390bda..57b09b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,15 @@ [versions] annotations = "24.1.0" +hikari = "5.1.0" jackson = "2.16.1" junit = "5.10.2" log4j2 = "2.22.1" lombok = "1.18.30" +sqlite = "3.44.1.0" [libraries] annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" } +hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" } jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson"} jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson"} jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson"} @@ -16,6 +19,7 @@ log4j2-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log log4j2-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2"} log4j2-slf4j = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j2"} lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } +sqlite = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite"} [bundles] jackson = ["jackson-databind", "jackson-annotations", "jackson-datatype-jsr310"] diff --git a/settings.gradle.kts b/settings.gradle.kts index 61319e6..6cd35f8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ rootProject.name = "ChatBot" include("server") include("bot-api") +include("bot-database") include("bots") include("management")