add database support

main
jbb01 10 months ago committed by jbb01
parent 82f5a125bd
commit bfae7c9802

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

@ -0,0 +1,9 @@
package eu.jonahbauer.chat.bot.database;
import lombok.experimental.StandardException;
import java.sql.SQLException;
@StandardException
public class BindException extends SQLException {
}

@ -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> 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 <T extends Throwable> void transaction(@NotNull ThrowingRunnable<T> 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, T extends Throwable> R transaction(@NotNull ThrowingCallable<R, T> 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 <T extends Record> @NotNull List<T> findAll(@NotNull Class<T> type) throws SQLException {
return executeQuery(type, "SELECT * FROM " + Helper.getQuotedTableName(type));
}
public <T extends Record & Entity<T>> @NotNull Optional<T> findById(@NotNull Class<T> type, long id) throws SQLException {
return findWhere(type, "id", id);
}
@SuppressWarnings("Convert2MethodRef")
public <T extends Record> @NotNull List<T> findAllWhere(@NotNull Class<T> type, @NotNull String component, Object value) throws SQLException {
return findWhere0(type, component, value, (t, query, customizer) -> executeQuery(t, query, customizer));
}
@SuppressWarnings("Convert2MethodRef")
public <T extends Record> @NotNull Optional<T> findWhere(@NotNull Class<T> type, @NotNull String component, Object value) throws SQLException {
return findWhere0(type, component, value, (t, query, customizer) -> executeUniqueQuery(t, query, customizer));
}
@SneakyThrows(NoSuchMethodException.class)
private <T extends Record, R> R findWhere0(@NotNull Class<T> type, @NotNull String component, Object value, @NotNull FindWhere<T, R> 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<T extends Record, R> {
R find(@NotNull Class<T> type, @Language("SQL") @NotNull String query, @NotNull ThrowingConsumer<PreparedStatement, SQLException> customizer) throws SQLException;
}
public <T extends Record> @NotNull List<T> bindAll(@NotNull Class<T> type, @NotNull ResultSet result) throws SQLException {
return bindAll(type, result, "");
}
public <T extends Record> @NotNull List<T> bindAll(@NotNull Class<T> type, @NotNull ResultSet result, @NotNull String prefix) throws SQLException {
var out = new ArrayList<T>();
var binder = Binder.get(type);
while (result.next()) {
out.add(binder.bind(result, prefix));
}
return Collections.unmodifiableList(out);
}
public <T extends Record> @NotNull Optional<T> bind(@NotNull Class<T> type, @NotNull ResultSet result) throws SQLException {
return bind(type, result, "");
}
public <T extends Record> @NotNull Optional<T> bind(@NotNull Class<T> 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 <T extends Record & Entity<T>> boolean delete(@NotNull T entity) throws SQLException {
return delete((Class<T>) entity.getClass(), entity.id());
}
public <T extends Record & Entity<T>> boolean delete(@NotNull Class<T> 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 <T extends Record> long insert0(@NotNull T record) throws SQLException {
@SuppressWarnings("unchecked")
var type = (Class<T>) record.getClass();
var connection = getConnection0();
try {
return Inserter.get(type).insert(connection, record);
} finally {
close(connection);
}
}
public <T extends Record> T save(@NotNull T record) throws SQLException {
@SuppressWarnings("unchecked")
var type = (Class<T>) 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 <T extends Record & Entity<T>> boolean update(@NotNull T entity) throws SQLException {
@SuppressWarnings("unchecked")
var type = (Class<T>) entity.getClass();
var connection = getConnection0();
try {
return Updater.get(type).update(connection, entity);
} finally {
close(connection);
}
}
@SuppressWarnings("SqlWithoutWhere")
public int truncate(@NotNull Class<? extends Record> type) throws SQLException {
return executeUpdate("DELETE FROM " + Helper.getQuotedTableName(type));
}
public <T extends Record> @NotNull List<T> executeQuery(@NotNull Class<T> 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 <R extends Record, T extends Throwable> @NotNull List<R> executeQuery(@NotNull Class<R> 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 <T extends Record> @NotNull Optional<T> executeUniqueQuery(@NotNull Class<T> 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 <R extends Record, T extends Throwable> @NotNull Optional<R> executeUniqueQuery(@NotNull Class<R> 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 <T extends Throwable> 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, T extends Throwable> {
R call() throws T;
}
public interface ThrowingRunnable<T extends Throwable> {
void run() throws T;
}
public interface ThrowingConsumer<S, T extends Throwable> {
void accept(S object) throws T;
}
}

@ -0,0 +1,12 @@
package eu.jonahbauer.chat.bot.database;
import eu.jonahbauer.chat.bot.database.impl.Helper;
public interface Entity<T extends Record & Entity<T>> {
long id();
@SuppressWarnings("unchecked")
default T withId(long id) {
return Helper.getWithId((Class<T>) getClass()).withId((T) this, id);
}
}

@ -0,0 +1,9 @@
package eu.jonahbauer.chat.bot.database;
import lombok.experimental.StandardException;
import java.sql.SQLException;
@StandardException
public class NonUniqueResultException extends SQLException {
}

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

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

@ -0,0 +1,19 @@
package eu.jonahbauer.chat.bot.database.impl;
import org.jetbrains.annotations.NotNull;
abstract class AbstractFactory<F> extends ClassValue<F> {
@Override
protected final @NotNull F computeValue(Class<?> type) {
if (!type.isRecord()) throw new IllegalArgumentException();
return bridge(type);
}
@SuppressWarnings("unchecked")
private <T extends Record> @NotNull F bridge(@NotNull Class<?> type) {
return computeValue0((Class<T>) type);
}
protected abstract <T extends Record> @NotNull F computeValue0(@NotNull Class<T> type);
}

@ -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<T extends Record> {
@SuppressWarnings("unchecked")
static <T extends Record> @NotNull Binder<T> get(@NotNull Class<T> type) {
return (Binder<T>) 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;
}

@ -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<Binder<?>> {
public static final @NotNull BinderFactory INSTANCE = new BinderFactory();
private BinderFactory() {}
@Override
@SneakyThrows
protected @NotNull <T extends Record> Binder<T> computeValue0(@NotNull Class<T> type) {
var components = type.getRecordComponents();
var columns = new ArrayList<String>();
var adapters = new ArrayList<TypeAdapter<?>>();
var types = new ArrayList<Class<?>>();
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<T extends Record> implements Binder<T> {
private final @NotNull Constructor<T> constructor;
private final @NotNull List<@NotNull String> columns;
private final @NotNull List<@NotNull TypeAdapter<?>> adapters;
private BinderImpl(@NotNull Constructor<T> 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);
}
}
}
}

@ -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<String, String>();
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<? extends Record> 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 <T extends Record & Entity<T>> WithId<T> getWithId(@NotNull Class<T> type) {
return (WithId<T>) 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 <T extends Record & Entity<T>> WithId<T> 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();
}
}

@ -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<T extends Record> {
@SuppressWarnings("unchecked")
static <T extends Record> @NotNull Inserter<T> get(@NotNull Class<T> type) {
return (Inserter<T>) InserterFactory.INSTANCE.get(type);
}
long insert(@NotNull Connection connection, @NotNull T entity) throws SQLException;
}

@ -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 <T extends Record> @NotNull Inserter<?> computeValue0(@NotNull Class<T> 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);
}
}
};
}
}

@ -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<T extends Record> {
@SuppressWarnings("unchecked")
static <T extends Record> @NotNull Preparer<T> get(@NotNull Class<T> type) {
return (Preparer<T>) 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();
}

@ -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<Preparer<?>> {
public static final @NotNull PreparerFactory INSTANCE = new PreparerFactory();
private PreparerFactory() {}
@Override
protected <T extends Record> @NotNull Preparer<T> computeValue0(@NotNull Class<T> type) {
var components = type.getRecordComponents();
var columns = new ArrayList<String>(components.length - 1);
var accessors = new ArrayList<Method>(components.length - 1);
var adapters = new ArrayList<TypeAdapter<?>>(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<T extends Record> implements Preparer<T> {
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;
}
}
}

@ -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<T extends Record & Entity> {
@SuppressWarnings("unchecked")
static <T extends Record & Entity> @NotNull Updater<T> get(@NotNull Class<T> type) {
return (Updater<T>) UpdaterFactory.INSTANCE.get(type);
}
boolean update(@NotNull Connection connection, @NotNull T entity) throws SQLException;
}

@ -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 <T extends Record> @NotNull Updater<?> computeValue0(@NotNull Class<T> type) {
if (!Entity.class.isAssignableFrom(type)) throw new IllegalArgumentException();
return bridge(type);
}
@SuppressWarnings("unchecked")
private <T extends Record & Entity> @NotNull Updater<T> bridge(@NotNull Class<?> type) {
return computeValue1((Class<T>) type);
}
private <T extends Record & Entity> @NotNull Updater<T> computeValue1(@NotNull Class<T> 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;
}
};
}
}

@ -0,0 +1,8 @@
package eu.jonahbauer.chat.bot.database.impl;
import eu.jonahbauer.chat.bot.database.Entity;
@FunctionalInterface
public interface WithId<T extends Record & Entity<T>> {
T withId(T entity, long id);
}

@ -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<E extends Enum<E>> implements TypeAdapter<E> {
private final @NotNull Class<E> 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));
}
}

@ -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<Instant> {
@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);
}
}
}

@ -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<T> implements TypeAdapter<T> {
private final @NotNull Class<T> type;
JdbcTypeAdapter(@NotNull Class<T> 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);
}
}
}

@ -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<T> {
@SuppressWarnings("unchecked")
static <T> @NotNull TypeAdapter<T> get(@NotNull Class<T> type) {
if (type == Instant.class) {
return (TypeAdapter<T>) new InstantTypeAdapter();
} else if (Enum.class.isAssignableFrom(type)) {
return (TypeAdapter<T>) newEnumTypeAdapter(type);
} else {
return new JdbcTypeAdapter<>(type);
}
}
@SuppressWarnings("unchecked")
private static <E extends Enum<E>> EnumTypeAdapter<?> newEnumTypeAdapter(Class<?> type) {
return new EnumTypeAdapter<>((Class<E>) 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;
}

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

@ -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 <T extends Record & Entity<T>> void crud(@NotNull Class<T> type, @NotNull PizzaBaker<T> 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<T extends Record & Entity<T>> {
@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<Pizza> {}
@Table("pizza")
public record AnnotatedPizza(long id, @Column("restaurant_id") long restaurant, int number, @NotNull String name, @NotNull String ingredients, double price) implements Entity<AnnotatedPizza> {}
}

@ -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<TestEntity> {}
public record TestEntity2(String foo, String bar, long id) implements Entity<TestEntity2> {}
@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));
}
}

@ -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"]

@ -4,6 +4,7 @@ rootProject.name = "ChatBot"
include("server")
include("bot-api")
include("bot-database")
include("bots")
include("management")

Loading…
Cancel
Save