Compare commits
10 Commits
eadf1eaf5b
...
33bee511e5
Author | SHA1 | Date |
---|---|---|
jbb01 | 33bee511e5 | 10 months ago |
jbb01 | 2fe725134a | 10 months ago |
jbb01 | 6d0335b8d9 | 10 months ago |
jbb01 | 8bc0f8cce0 | 10 months ago |
jbb01 | dfa8dcb6e9 | 10 months ago |
jbb01 | 332c73f5fd | 10 months ago |
jbb01 | e8ace4fea5 | 10 months ago |
jbb01 | bfae7c9802 | 10 months ago |
jbb01 | 82f5a125bd | 10 months ago |
jbb01 | 0477142233 | 10 months ago |
@ -0,0 +1,50 @@
|
|||||||
|
.gradle
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### Git ###
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
### Docker ###
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea/modules.xml
|
||||||
|
.idea/jarRepositories.xml
|
||||||
|
.idea/compiler.xml
|
||||||
|
.idea/libraries/
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
|
!**/src/main/**/out/
|
||||||
|
!**/src/test/**/out/
|
||||||
|
|
||||||
|
### Eclipse ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
bin/
|
||||||
|
!**/src/main/**/bin/
|
||||||
|
!**/src/test/**/bin/
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
### Mac OS ###
|
||||||
|
.DS_Store
|
@ -0,0 +1,33 @@
|
|||||||
|
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /root/app
|
||||||
|
|
||||||
|
# Download Gradle
|
||||||
|
COPY ./gradle ./gradle
|
||||||
|
COPY ./gradlew ./
|
||||||
|
RUN ./gradlew --no-daemon --info --stacktrace
|
||||||
|
|
||||||
|
# Download Dependencies
|
||||||
|
COPY ./buildSrc ./buildSrc
|
||||||
|
COPY ./settings.gradle.kts ./
|
||||||
|
COPY ./bot-api/build.gradle.kts ./bot-api/
|
||||||
|
COPY ./bot-database/build.gradle.kts ./bot-database/
|
||||||
|
COPY ./management/build.gradle.kts ./management/
|
||||||
|
COPY ./server/build.gradle.kts ./server/
|
||||||
|
COPY ./bots/ping-bot/build.gradle.kts ./bots/ping-bot/
|
||||||
|
COPY ./bots/pizza-bot/build.gradle.kts ./bots/pizza-bot/
|
||||||
|
RUN ./gradlew deps --no-daemon --info --stacktrace
|
||||||
|
|
||||||
|
# Build Application
|
||||||
|
COPY . .
|
||||||
|
RUN ./gradlew build :server:jlink --no-daemon --info --stacktrace
|
||||||
|
|
||||||
|
FROM alpine:3.19.1
|
||||||
|
|
||||||
|
WORKDIR /opt/chat/
|
||||||
|
COPY --from=build /root/app/server/build/dist/jre /opt/chat/
|
||||||
|
|
||||||
|
ENV JAVA_HOME="/opt/chat"
|
||||||
|
ENV JAVA_OPTS="--enable-preview"
|
||||||
|
ENV CHAT_BOT_CONFIG="file:/etc/chat/config.json"
|
||||||
|
ENTRYPOINT $JAVA_HOME/bin/java $JAVA_OPTS --module eu.jonahbauer.chat.server
|
@ -0,0 +1,21 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.test;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.api.ChatBot;
|
||||||
|
import eu.jonahbauer.chat.bot.config.BotConfig;
|
||||||
|
import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
public final class ChatBotFactoryAccess {
|
||||||
|
|
||||||
|
private ChatBotFactoryAccess() {}
|
||||||
|
|
||||||
|
public static <T extends ChatBot> T create(@NotNull BotConfig config, @NotNull Supplier<T> supplier) {
|
||||||
|
return ScopedValue.where(ChatBotFactory.BOT_CONFIG, config).get(supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T extends ChatBot> T create(@NotNull Supplier<T> supplier) {
|
||||||
|
return create(BotConfig.EMPTY, supplier);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.test;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.api.Chat;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class MockChat implements Chat {
|
||||||
|
private final @NotNull List<@NotNull Message> messages = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean send(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId) {
|
||||||
|
messages.add(new Message(channel, name, message, bottag, publicId));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull List<@NotNull Message> getMessages() {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Message(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId) {
|
||||||
|
public Message(@NotNull String channel, @NotNull String name, @NotNull String message) {
|
||||||
|
this(channel, name, message, true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
module eu.jonahbauer.chat.bot.api.fixtures {
|
||||||
|
exports eu.jonahbauer.chat.bot.test;
|
||||||
|
|
||||||
|
requires eu.jonahbauer.chat.bot.api;
|
||||||
|
requires eu.jonahbauer.chat.bot.config;
|
||||||
|
requires static transitive org.jetbrains.annotations;
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
plugins {
|
||||||
|
id("java-library")
|
||||||
|
id("chat-bot.java-conventions")
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "eu.jonahbauer.chat"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnlyApi(libs.annotations)
|
||||||
|
implementation(libs.sqlite)
|
||||||
|
implementation(libs.hikari)
|
||||||
|
implementation(libs.slf4j)
|
||||||
|
}
|
@ -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.jetbrains.annotations.NotNull;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
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 = LoggerFactory.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.slf4j;
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
plugins {
|
||||||
|
id("chat-bot.bot-conventions")
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "eu.jonahbauer.chat.bots"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":bot-database"))
|
||||||
|
testImplementation(testFixtures(project(":bot-api")))
|
||||||
|
}
|
@ -0,0 +1,692 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.api.ChatBot;
|
||||||
|
import eu.jonahbauer.chat.bot.api.Message.Post;
|
||||||
|
import eu.jonahbauer.chat.bot.config.BotConfig;
|
||||||
|
import eu.jonahbauer.chat.bot.database.Database;
|
||||||
|
import eu.jonahbauer.chat.bot.database.NonUniqueResultException;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.*;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.Order;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.OrderItem;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.Pizza;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.User;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.view.OrderDetail;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.view.OrderItemDetail;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.util.ArgumentParser;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.util.Pair;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.jetbrains.annotations.VisibleForTesting;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
import java.util.function.BinaryOperator;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static eu.jonahbauer.chat.bot.pizza.util.ArgumentParser.*;
|
||||||
|
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.*;
|
||||||
|
import static java.util.FormatProcessor.FMT;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class PizzaBot extends ChatBot {
|
||||||
|
private static final @NotNull ScopedValue<Double> ANNOYANCE = ScopedValue.newInstance();
|
||||||
|
private static final @NotNull ScopedValue<User> USER = ScopedValue.newInstance();
|
||||||
|
|
||||||
|
private static final @NotNull BotConfig.Key.OfStringArray PRIMARY_CHANNELS = BotConfig.Key.ofStringArray("primary_channels");
|
||||||
|
private static final @NotNull BotConfig.Key.OfString DATABASE = BotConfig.Key.ofString("database");
|
||||||
|
|
||||||
|
private static final @NotNull DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
.withZone(ZoneId.of("Europe/Berlin"))
|
||||||
|
.withLocale(Locale.GERMANY);
|
||||||
|
private static final @NotNull List<@NotNull String> ANNOYED_RESPONSES = List.of(
|
||||||
|
"Es ist so dunkel, ich kann dich nicht hören.",
|
||||||
|
"Jetzt schrei doch nicht so!",
|
||||||
|
"Fresse!"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final @Nullable Set<@NotNull String> primaryChannels;
|
||||||
|
private final @NotNull PizzaService service;
|
||||||
|
|
||||||
|
public PizzaBot() {
|
||||||
|
super("Marek");
|
||||||
|
this.primaryChannels = getConfig().get(PRIMARY_CHANNELS).map(Set::copyOf).orElse(null);
|
||||||
|
this.service = new PizzaService(new Database(getConfig().require(DATABASE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
PizzaBot(@Nullable Set<@NotNull String> primaryChannels, @NotNull Database database) {
|
||||||
|
super("Marek");
|
||||||
|
this.primaryChannels = primaryChannels;
|
||||||
|
this.service = new PizzaService(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onMessage(@NotNull Post post) {
|
||||||
|
var message = post.message();
|
||||||
|
if (message.length() < 6) return;
|
||||||
|
|
||||||
|
var command = message.substring(0, 6);
|
||||||
|
|
||||||
|
var runnable = (Runnable) () -> {
|
||||||
|
try {
|
||||||
|
if ("!marek".equals(command) || "!Marek".equals(command)) {
|
||||||
|
handle(post);
|
||||||
|
} else if ("!MAREK".equals(command) && !ANNOYED_RESPONSES.isEmpty()) {
|
||||||
|
handleAnnoyed();
|
||||||
|
} else if ("!marek".equalsIgnoreCase(command)) {
|
||||||
|
var uppercase = command.chars().filter(chr -> 'A' <= chr && chr <= 'Z').count();
|
||||||
|
ScopedValue.runWhere(ANNOYANCE, uppercase / 5d, () -> handle(post));
|
||||||
|
}
|
||||||
|
} catch (PizzaBotException ex) {
|
||||||
|
post(ex.getMessage());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (post.userId() != null && post.userName() != null) {
|
||||||
|
ScopedValue.runWhere(USER, new User(post.userId(), post.userName()), runnable);
|
||||||
|
} else {
|
||||||
|
runnable.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handle(@NotNull Post post) {
|
||||||
|
if (!isPrimaryChannel(post.channel())) {
|
||||||
|
handleNonPrimaryChannel();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
dispatch(ArgumentParser.parse(post.message().substring(6)));
|
||||||
|
} catch (SQLException ex) {
|
||||||
|
log.error("Fehler beim Verarbeiten von {}.", post, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleAnnoyed() {
|
||||||
|
assert !ANNOYED_RESPONSES.isEmpty();
|
||||||
|
|
||||||
|
var idx = (int) (Math.random() * ANNOYED_RESPONSES.size());
|
||||||
|
post(ANNOYED_RESPONSES.get(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleNonPrimaryChannel() {
|
||||||
|
assert primaryChannels != null;
|
||||||
|
|
||||||
|
if (primaryChannels.size() == 1) {
|
||||||
|
post(STR."Bitte benutze den Channel \{primaryChannels.iterator().next()}.");
|
||||||
|
} else {
|
||||||
|
var channels = new StringBuilder();
|
||||||
|
var it = primaryChannels.iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
var channel = it.next();
|
||||||
|
if (!it.hasNext()) {
|
||||||
|
channels.append(" oder ");
|
||||||
|
} else if (!channels.isEmpty()) {
|
||||||
|
channels.append(", ");
|
||||||
|
}
|
||||||
|
channels.append(channel);
|
||||||
|
}
|
||||||
|
post(STR."Bitte benutze einen der Channel \{channels.toString()}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatch(@NotNull List<@NotNull String> args) throws SQLException {
|
||||||
|
if (args.isEmpty()) {
|
||||||
|
helpUser();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var argc = args.size();
|
||||||
|
var command = args.getFirst();
|
||||||
|
switch (command) {
|
||||||
|
case "--user", "-u" -> runAs(args);
|
||||||
|
case "help" -> {
|
||||||
|
if (argc > 1 && "--all".equals(args.get(1))) {
|
||||||
|
helpAdmin();
|
||||||
|
} else {
|
||||||
|
helpUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "info" -> info();
|
||||||
|
case "order" -> {
|
||||||
|
if (argc == 1) throw new PizzaBotException("too few arguments");
|
||||||
|
switch (args.get(1)) {
|
||||||
|
case "revoke" -> {
|
||||||
|
if (argc == 2) orderRevoke();
|
||||||
|
else if (argc != 3) throw new PizzaBotException("too many arguments");
|
||||||
|
else switch (args.get(2)) {
|
||||||
|
case "--all" -> orderRevokeAll();
|
||||||
|
case String s when isLong(s) -> orderRevoke(toLong(s));
|
||||||
|
case String name -> orderRevoke(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "list" -> {
|
||||||
|
if (argc == 2) orderList();
|
||||||
|
// else if (argc == 3 && "--all".equals(args.get(2))) orderListAll();
|
||||||
|
else throw new PizzaBotException("invalid command");
|
||||||
|
}
|
||||||
|
case "add" -> {
|
||||||
|
if (argc < 4) throw new PizzaBotException("too few arguments");
|
||||||
|
else if (argc > 4) throw new PizzaBotException("too many arguments");
|
||||||
|
orderAdd(args.get(2), args.get(3));
|
||||||
|
}
|
||||||
|
case String name when argc == 2 -> order(name, null);
|
||||||
|
case String name -> order(name, String.join(" ", args.subList(2, args.size())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "summary" -> {
|
||||||
|
if (argc == 1) summary();
|
||||||
|
else if (argc == 2 && "--all".equals(args.get(1))) summaryAll();
|
||||||
|
else if (argc == 2) summary(args.get(1));
|
||||||
|
else throw new PizzaBotException("invalid command");
|
||||||
|
}
|
||||||
|
case "check" -> {
|
||||||
|
if (argc == 1) check();
|
||||||
|
else if (argc == 2 && "--all".equals(args.get(1))) checkAll();
|
||||||
|
else if (argc == 2) check(args.get(1));
|
||||||
|
else throw new PizzaBotException("invalid command");
|
||||||
|
}
|
||||||
|
case "pay" -> {
|
||||||
|
if (argc == 1) throw new PizzaBotException("missing argument");
|
||||||
|
else if (argc == 2) pay(toDouble(args.get(1)));
|
||||||
|
else if (argc == 3) pay(args.get(1), toDouble(args.get(2)));
|
||||||
|
else throw new PizzaBotException("too many arguments");
|
||||||
|
}
|
||||||
|
case "payment" -> {
|
||||||
|
if (argc == 1) throw new PizzaBotException("too few arguments");
|
||||||
|
switch (args.get(1)) {
|
||||||
|
case "confirm" -> {
|
||||||
|
if (argc == 2) throw new PizzaBotException("too few arguments");
|
||||||
|
else if (argc == 3 && "--all".equals(args.get(2))) throw new PizzaBotException("missing arguments");
|
||||||
|
else if (argc == 3) paymentConfirm(toLong(args.get(2)), null);
|
||||||
|
else if (argc == 4 && "--all".equals(args.get(2))) paymentConfirmAll(args.get(3));
|
||||||
|
else if (argc == 4) paymentConfirm(toLong(args.get(2)), args.get(3));
|
||||||
|
else throw new PizzaBotException("invalid command");
|
||||||
|
}
|
||||||
|
case "void" -> {
|
||||||
|
if (argc == 2) throw new PizzaBotException("too few arguments");
|
||||||
|
else if (argc == 3) paymentVoid(toLong(args.get(2)), null);
|
||||||
|
else if (argc == 4) paymentVoid(toLong(args.get(2)), args.get(3));
|
||||||
|
else throw new PizzaBotException("invalid command");
|
||||||
|
}
|
||||||
|
default -> throw new PizzaBotException("invalid command");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> throw new PizzaBotException("Unbekannter Befehl.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private @NotNull User getUser() {
|
||||||
|
if (USER.isBound()) {
|
||||||
|
return USER.get();
|
||||||
|
} else {
|
||||||
|
throw new PizzaBotException("Diese Aktion kann nur mit öffentlicher ID ausgeführt werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//<editor-fold desc="Admin" defaultstate="collapsed">
|
||||||
|
private void runAs(@NotNull List<String> args) throws SQLException {
|
||||||
|
// TODO
|
||||||
|
if (true) throw new PizzaBotException("nicht implementiert");
|
||||||
|
|
||||||
|
if (args.size() < 2) throw new PizzaBotException("missing argument");
|
||||||
|
|
||||||
|
var username = args.get(1);
|
||||||
|
// var user = service.getUserByEventAndName(, username);
|
||||||
|
//
|
||||||
|
// ScopedValue.runWhere(USER, user, () -> {
|
||||||
|
// try {
|
||||||
|
// dispatch(args.subList(2, args.size()));
|
||||||
|
// } catch (SQLException ex) {
|
||||||
|
// throw Lombok.sneakyThrow(ex);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// if (false) throw new SQLException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void orderAdd(@NotNull String targetOrder, @NotNull String newUserName) throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
var newUser = service.getUserByName(newUserName);
|
||||||
|
|
||||||
|
OrderItem item;
|
||||||
|
Pizza pizza;
|
||||||
|
List<UserWithPayment> users;
|
||||||
|
try {
|
||||||
|
var targetId = Long.parseLong(targetOrder);
|
||||||
|
var detail = service.getItemById(targetId)
|
||||||
|
.orElseThrow(() -> new PizzaBotException(STR."Die Bestellung mit Nummer \{targetId} existiert nicht."));
|
||||||
|
item = detail.item();
|
||||||
|
pizza = detail.pizza();
|
||||||
|
users = detail.users();
|
||||||
|
} catch (NumberFormatException _) {
|
||||||
|
var user = service.getUserByEventAndName(order.eventId(), targetOrder);
|
||||||
|
try {
|
||||||
|
item = service.getItemByUser(order.id(), user.id())
|
||||||
|
.orElseThrow(() -> new PizzaBotException(STR."\{user.username()} hat keine Bestellung aufgegeben."));
|
||||||
|
pizza = service.getPizzaById(item.pizzaId()).orElseThrow();
|
||||||
|
users = service.getUsersByItem(item);
|
||||||
|
} catch (NonUniqueResultException ex) {
|
||||||
|
throw new PizzaBotException(STR."\{user.username()} hat mehr als eine Bestellung aufgegeben. Bitte gibt die Positionsnummer an.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payed = users.stream().filter(UserWithPayment::payed).map(UserWithPayment::username).toList();
|
||||||
|
if (!payed.isEmpty()) {
|
||||||
|
throw new PizzaBotException(STR."\{join(payed)} \{payed.size() == 1 ? "hat" : "haben"} schon bezahlt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.stream().anyMatch(u -> u.id() == newUser.id())) {
|
||||||
|
throw new PizzaBotException(STR."\{newUser.username()} nimmt bereits an dieser Bestellung teil.");
|
||||||
|
}
|
||||||
|
|
||||||
|
service.add(item, newUser, false);
|
||||||
|
post(STR."\{newUser.username()} wurde zur Bestellung \{item.id()} (\{pizza.name()}) von \{join(users, UserWithPayment::username)} hinzugefügt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt eine Übersicht alle Bestellungen während der Veranstaltung.
|
||||||
|
*/
|
||||||
|
private void summaryAll() throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
summary0(order.eventName(), service.getItemDetailByEvent(order.eventId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt eine Übersicht der Positionen der laufenden Bestellung.
|
||||||
|
*/
|
||||||
|
private void summary() throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
summary0(order.name(), service.getItemDetailByOrder(order.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt eine Übersicht der Positionen der Bestellung mit dem angegebenen Namen.
|
||||||
|
*/
|
||||||
|
private void summary(@NotNull String name) throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
var other = service.getOrderByEventAndName(order.eventId(), name);
|
||||||
|
summary0(other.name(), service.getItemDetailByOrder(other.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void summary0(@NotNull String name, @NotNull Collection<OrderItemDetail> details) {
|
||||||
|
var items = details.stream().collect(Collectors.groupingBy(
|
||||||
|
detail -> Pair.of(detail.pizza(), detail.notes()),
|
||||||
|
() -> new TreeMap<>(Comparator.comparing(Pair::first)),
|
||||||
|
Collectors.collectingAndThen(
|
||||||
|
Collectors.groupingBy(
|
||||||
|
OrderItemDetail::id,
|
||||||
|
Collectors.mapping(OrderItemDetail::userName, Collectors.joining("+"))
|
||||||
|
),
|
||||||
|
Map::values
|
||||||
|
)
|
||||||
|
));
|
||||||
|
var lines = new ArrayList<String>(items.size() + 2);
|
||||||
|
lines.add("Übersicht " + name);
|
||||||
|
lines.add("");
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
lines.add("(keine Einträge)");
|
||||||
|
} else {
|
||||||
|
items.forEach((item, users) -> {
|
||||||
|
var counts = users.stream().collect(Collectors.groupingBy(
|
||||||
|
Function.identity(),
|
||||||
|
Collectors.counting()
|
||||||
|
));
|
||||||
|
var usersString = counts.entrySet().stream()
|
||||||
|
.sorted(Map.Entry.comparingByKey())
|
||||||
|
.map(entry -> entry.getValue() == 1 ? entry.getKey() : entry.getValue() + "x " + entry.getKey())
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
var pizza = item.first().name().toUpperCase(Locale.ROOT);
|
||||||
|
var notes = item.second() != null ? " " + item.second() : "";
|
||||||
|
lines.add(STR."\{tabular(users.size())}x \{pizza}\{notes} (\{usersString})");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
post(String.join("\n", lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt die zusammengefasste Rechnung für alle Bestellungen während der Veranstaltung.
|
||||||
|
*/
|
||||||
|
private void checkAll() throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
var details = service.getItemDetailByEvent(order.eventId());
|
||||||
|
|
||||||
|
var tips = details.stream().collect(Collectors.groupingBy(
|
||||||
|
OrderItemDetail::order,
|
||||||
|
Collectors.collectingAndThen(Collectors.toList(), list -> calculateTip(list.getFirst().orderPayment(), list))
|
||||||
|
));
|
||||||
|
|
||||||
|
var items = details.stream().collect(Collectors.groupingBy(
|
||||||
|
OrderItemDetail::user,
|
||||||
|
Collectors.reducing(Receipt.empty(), item -> item.receipt(tips.get(item.order())), Receipt::sum)
|
||||||
|
));
|
||||||
|
check0(order.eventName(), tips.values().stream().anyMatch(tip -> tip > 0), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt die Rechnung für der laufenden Bestellung.
|
||||||
|
*/
|
||||||
|
private void check() throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
check0(order.order());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt die Rechnung für die Bestellung mit dem angegebenen Namen.
|
||||||
|
*/
|
||||||
|
private void check(@NotNull String name) throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
var other = service.getOrderByEventAndName(order.eventId(), name);
|
||||||
|
check0(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double calculateTip(@Nullable Double payment, List<@NotNull OrderItemDetail> details) {
|
||||||
|
var total = details.stream().mapToDouble(OrderItemDetail::userPrice).sum();
|
||||||
|
return payment == null ? 0 : payment / total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void check0(@NotNull Order order) throws SQLException {
|
||||||
|
var details = service.getItemDetailByOrder(order.id());
|
||||||
|
|
||||||
|
var tip = calculateTip(order.payment(), details);
|
||||||
|
var items = details.stream().collect(Collectors.groupingBy(
|
||||||
|
OrderItemDetail::user,
|
||||||
|
Collectors.reducing(Receipt.empty(), item -> item.receipt(tip), Receipt::sum)
|
||||||
|
));
|
||||||
|
check0(order.name(), order.payment() != null, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void check0(@NotNull String name, boolean tipped, @NotNull Map<User, @NotNull Receipt> items) {
|
||||||
|
var lines = new ArrayList<String>();
|
||||||
|
lines.add("Rechnung für " + name + (tipped ? " (mit Trinkgeld)" : ""));
|
||||||
|
lines.add("alle Angaben ohne Gewähr");
|
||||||
|
lines.add("");
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
lines.add("(keine Einträge)");
|
||||||
|
} else {
|
||||||
|
var toString = (BiConsumer<String, Receipt>) (user, receipt) -> {
|
||||||
|
if (receipt.payed()) {
|
||||||
|
lines.add(strikethrough(user + ": " + receipt.totalAsString()));
|
||||||
|
} else if (receipt.unpayed()) {
|
||||||
|
lines.add(user + ": " + receipt.totalAsString());
|
||||||
|
} else {
|
||||||
|
lines.add(user + ": " + strikethrough(receipt.totalAsString()) + " " + receipt.pendingAsString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
items.forEach((user, receipt) -> toString.accept(user.username(), receipt));
|
||||||
|
lines.add("");
|
||||||
|
toString.accept("Gesamt", items.values().stream().reduce(Receipt::sum).orElseThrow());
|
||||||
|
}
|
||||||
|
post(String.join("\n", lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pay(double amount) throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
service.save(order.order().withPayment(amount));
|
||||||
|
post(FMT."Zahlung von %.2f\{amount} für \{order.name()} wurde gespeichert");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pay(@NotNull String name, double amount) throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
var other = service.getOrderByEventAndName(order.eventId(), name);
|
||||||
|
service.save(other.withPayment(amount));
|
||||||
|
post(FMT."Zahlung von %.2f\{amount} für \{other.name()} wurde gespeichert");
|
||||||
|
}
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Beginnt eine neue Bestellung.
|
||||||
|
// * @param restaurant die Restaurant-ID
|
||||||
|
// */
|
||||||
|
// private void orderStart(@NotNull String restaurant, @NotNull String deadline) {
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /**
|
||||||
|
// * Beginnt eine neue Bestellung.
|
||||||
|
// * @param restaurantId die Restaurant-ID
|
||||||
|
// */
|
||||||
|
// private void orderStart(long restaurantId, @NotNull String deadline) {
|
||||||
|
// var restaurant = restaurantRepository.getById(restaurantId)
|
||||||
|
// .orElseThrow(() -> new BreakException(STR."Das Restaurant mit der ID \{restaurantId} existiert nicht."));
|
||||||
|
// var order = new Order(-1, , restaurantId, , , null);
|
||||||
|
// orderRepository.save(order);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /**
|
||||||
|
// * Beendet eine Bestellung.
|
||||||
|
// * @param payment der gezahlte Betrag inkl. Trinkgeld
|
||||||
|
// */
|
||||||
|
// private void orderFinish(double payment) {
|
||||||
|
// var order = service.getCurrentOrderAsAdmin();
|
||||||
|
// orderRepository.save(order.withPayment(payment));
|
||||||
|
// check(order.id());
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestätigt den Zahlungseingang für alle ausstehenden Zahlungen der laufenden Veranstaltung eines Benutzers.
|
||||||
|
*/
|
||||||
|
private void paymentConfirmAll(@NotNull String name) throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
var user = service.getUserByEventAndName(order.eventId(), name);
|
||||||
|
var count = service.paymentByEventAndUser(order.eventId(), user.id(), true);
|
||||||
|
post(STR."\{count} ausstehenden Zahlungen für \{user.username()} wurden bestätigt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestätigt den Zahlungseingang für eine Bestellung.
|
||||||
|
*/
|
||||||
|
private void paymentConfirm(long itemId, @Nullable String username) throws SQLException {
|
||||||
|
payment0(itemId, username, true, (pizza, user) -> STR."Zahlungseingang für \{pizza} (\{user}) wurde bestätigt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widerruft die Bestätigung eines Zahlungseingangs.
|
||||||
|
*/
|
||||||
|
private void paymentVoid(long itemId, @Nullable String username) throws SQLException {
|
||||||
|
payment0(itemId, username, false, (pizza, user) -> STR."Zahlungseingang für \{pizza} (\{user}) wurde bestätigt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void payment0(long itemId, @Nullable String username, boolean payed, @NotNull BinaryOperator<String> message) throws SQLException {
|
||||||
|
var _ = service.getCurrentOrder().checkAdminAccess(getUser());
|
||||||
|
var summary = service.getItemById(itemId)
|
||||||
|
.orElseThrow(() -> new PizzaBotException(STR."Die Bestellung mit Nummer \{itemId} existiert nicht."));
|
||||||
|
if (username != null) {
|
||||||
|
var users = summary.users().stream().filter(u -> u.username().startsWith(username)).toList();
|
||||||
|
if (users.isEmpty()) {
|
||||||
|
throw new PizzaBotException("Der Benutzer existiert nicht oder hat nicht an der Bestellung teilgenommen.");
|
||||||
|
} else if (users.size() > 1) {
|
||||||
|
throw new PizzaBotException("Der Benutzer ist nicht eindeutig.");
|
||||||
|
} else {
|
||||||
|
var user = users.getFirst();
|
||||||
|
service.paymentByItemAndUser(itemId, user.id(), true);
|
||||||
|
post(message.apply(summary.pizza().name(), user.username()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
service.paymentByItem(summary.item().id(), payed);
|
||||||
|
post(message.apply(summary.pizza().name(), join(" + ", summary.users(), UserWithPayment::username)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//</editor-fold>
|
||||||
|
|
||||||
|
//<editor-fold desc="User" defaultstate="collapsed">
|
||||||
|
/**
|
||||||
|
* Zeigt Informationen zur aktuell laufenden Bestellung.
|
||||||
|
*/
|
||||||
|
private void info() throws SQLException {
|
||||||
|
var order = service.getCurrentOrder();
|
||||||
|
var result = service.getRestaurantWithPizzas(order.restaurantId());
|
||||||
|
var restaurant = result.first();
|
||||||
|
var pizzas = result.second();
|
||||||
|
|
||||||
|
var lines = new ArrayList<String>();
|
||||||
|
lines.add(STR."Pizzabestellung bei \{restaurant.name()} in \{restaurant.city()}");
|
||||||
|
lines.add(STR."Bestellannahmeschluss: \{DATE_FORMATTER.format(order.deadline())}");
|
||||||
|
pizzas.stream().map(Pizza::toPrettyString).forEach(lines::add);
|
||||||
|
post(String.join("\n", lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listet die eigenen Bestellungen auf.
|
||||||
|
*/
|
||||||
|
private void orderList() throws SQLException {
|
||||||
|
var user = getUser();
|
||||||
|
var current = service.getCurrentOrder();
|
||||||
|
|
||||||
|
var itemsByOrder = service.getItemDetailByEventAndUser(current.eventId(), user.id()).stream().collect(Collectors.groupingBy(
|
||||||
|
OrderItemDetail::order,
|
||||||
|
TreeMap::new,
|
||||||
|
Collectors.toUnmodifiableList()
|
||||||
|
));
|
||||||
|
|
||||||
|
var lines = new ArrayList<String>();
|
||||||
|
lines.add("Bestellungen von " + user.username());
|
||||||
|
if (itemsByOrder.isEmpty()) lines.add("(keine Einträge)");
|
||||||
|
itemsByOrder.forEach((order, items) -> {
|
||||||
|
lines.add("");
|
||||||
|
lines.add(order.name());
|
||||||
|
items.stream().map(OrderItemDetail::toPrettyString).forEach(lines::add);
|
||||||
|
});
|
||||||
|
post(String.join("\n", lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storniert die einzige eigene Bestellung.
|
||||||
|
*/
|
||||||
|
private void orderRevoke() throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkDeadline();
|
||||||
|
var user = getUser();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var item = service.getItemByUser(order.id(), user.id());
|
||||||
|
if (item.isEmpty()) {
|
||||||
|
post("Du hast keine Bestellung aufgegeben.");
|
||||||
|
} else {
|
||||||
|
orderRevoke0(item.get());
|
||||||
|
}
|
||||||
|
} catch (NonUniqueResultException ex) {
|
||||||
|
post("Du hast mehr als eine Bestellung aufgegeben. Bitte gib die Positionsnummer oder den Namen der Pizza an.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storniert die angegebene, eigene Bestellung.
|
||||||
|
*/
|
||||||
|
private void orderRevoke(long itemId) throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkDeadline();
|
||||||
|
var item = service.getItemById(itemId);
|
||||||
|
if (item.isEmpty() || item.get().item().orderId() != order.id()) {
|
||||||
|
post(STR."Die Position mit Nummer \{itemId} existiert nicht.");
|
||||||
|
} else {
|
||||||
|
orderRevoke0(item.get().checkUserAccess(getUser()).item());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storniert die eigene Bestellung der angegebene Pizza.
|
||||||
|
*/
|
||||||
|
private void orderRevoke(@NotNull String name) throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkDeadline();
|
||||||
|
var user = getUser();
|
||||||
|
try {
|
||||||
|
var item = service.getItemByPizzaName(order.id(), user.id(), name);
|
||||||
|
if (item.isEmpty()) {
|
||||||
|
post("Du hast keine solche Pizza bestellt.");
|
||||||
|
} else {
|
||||||
|
orderRevoke0(item.get());
|
||||||
|
}
|
||||||
|
} catch (NonUniqueResultException ex) {
|
||||||
|
post("Du hast mehr als eine solche Pizza bestellt. Bitte gib die Positionsnummer an.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void orderRevoke0(@NotNull OrderItem item) throws SQLException {
|
||||||
|
var pizza = service.getPizzaById(item.pizzaId()).orElseThrow();
|
||||||
|
service.delete(item);
|
||||||
|
post(STR."Die Position \{item.id()} (\{pizza.name()}) wurde storniert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storniert alle eigenen Bestellungen.
|
||||||
|
*/
|
||||||
|
private void orderRevokeAll() throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkDeadline();
|
||||||
|
var user = getUser();
|
||||||
|
var items = service.getItemsByUser(order.id(), user.id());
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
post("Du hast keine Bestellung aufgegeben.");
|
||||||
|
} else {
|
||||||
|
for (var item : items) {
|
||||||
|
service.delete(item);
|
||||||
|
}
|
||||||
|
post("Alle deine Bestellungen wurden storniert.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestellt die Pizza mit dem angegebenen Namen.
|
||||||
|
*/
|
||||||
|
private void order(@NotNull String nameOrNummer, @Nullable String notes) throws SQLException {
|
||||||
|
var order = service.getCurrentOrder().checkDeadline();
|
||||||
|
var pizza = service.getPizza(order.restaurantId(), nameOrNummer);
|
||||||
|
order0(order, pizza, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void order0(@NotNull OrderDetail order, @NotNull Pizza pizza, @Nullable String notes) throws SQLException {
|
||||||
|
var id = service.order(order, getUser(), pizza, notes);
|
||||||
|
post(STR."Pizza \{pizza.name()} wurde zur Bestellung hinzugefügt (Positionsnummer \{id}).");
|
||||||
|
}
|
||||||
|
//</editor-fold>
|
||||||
|
|
||||||
|
private void helpUser() {
|
||||||
|
post("""
|
||||||
|
Marek is back!
|
||||||
|
!marek info - Menü anzeigen
|
||||||
|
|
||||||
|
Bestellung
|
||||||
|
!marek order (NUMMER | NAME) [NOTIZEN] - Pizza bestellen
|
||||||
|
!marek order revoke [NUMMER | NAME | --all] - Bestellung(en) stornieren
|
||||||
|
!marek order list - Bestellungen auflisten
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void helpAdmin() {
|
||||||
|
post("""
|
||||||
|
!marek --user USER order (NUMMER | NAME) [NOTIZEN] - Pizza bestellen
|
||||||
|
!marek --user USER order revoke [NUMMER | NAME | --all] - Bestellung(en) stornieren
|
||||||
|
!marek --user USER order list - Bestellungen auflisten
|
||||||
|
|
||||||
|
!marek summary [NAME | --all] - Bestellübersicht
|
||||||
|
!marek check [NAME | --all] - Rechnung anzeigen
|
||||||
|
!marek pay [NAME] AMOUNT - Rechnungsbetrag angeben
|
||||||
|
!marek order list --all - Alle Bestellungen auflisten
|
||||||
|
!marek order add (NUMMER | NAME) USER - Fügt USER zur Bestellung mit Nummer NUMMER / von NAME hinzu
|
||||||
|
|
||||||
|
!marek payment confirm NUMMER [USER] - Zahlungseingang bestätigen
|
||||||
|
!marek payment confirm --all USER - Zahlungseingang bestätigen
|
||||||
|
!marek payment void NUMMER [USER] - Widerruf der Bestätigung des Zahlungseingangs
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void post(@NotNull String message, @Nullable String name, @Nullable Boolean bottag, @Nullable Boolean publicId) {
|
||||||
|
var annoyance = ANNOYANCE.orElse(0d);
|
||||||
|
if (annoyance == 0) {
|
||||||
|
super.post(message, name, bottag, publicId);
|
||||||
|
} else if (annoyance == 1) {
|
||||||
|
super.post(message.toUpperCase(Locale.ROOT), name, bottag, publicId);
|
||||||
|
} else {
|
||||||
|
var infuriatedMessage = message.codePoints()
|
||||||
|
.map(chr -> Math.random() < annoyance ? Character.toUpperCase(chr) : chr)
|
||||||
|
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
|
||||||
|
.toString();
|
||||||
|
super.post(infuriatedMessage, name, bottag, publicId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPrimaryChannel(@NotNull String channel) {
|
||||||
|
return primaryChannels == null || primaryChannels.contains(channel);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza;
|
||||||
|
|
||||||
|
import lombok.experimental.StandardException;
|
||||||
|
|
||||||
|
@StandardException
|
||||||
|
public class PizzaBotException extends RuntimeException {
|
||||||
|
}
|
@ -0,0 +1,298 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.database.Database;
|
||||||
|
import eu.jonahbauer.chat.bot.database.NonUniqueResultException;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.*;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.*;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.view.OrderDetail;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.view.OrderItemDetail;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.util.Pair;
|
||||||
|
import org.intellij.lang.annotations.Language;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public class PizzaService {
|
||||||
|
private final @NotNull Database database;
|
||||||
|
|
||||||
|
public PizzaService(@NotNull Database database) {
|
||||||
|
this.database = Objects.requireNonNull(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull OrderDetail getCurrentOrder() throws SQLException {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = "SELECT * FROM order_detail WHERE active = TRUE ORDER BY deadline DESC LIMIT 1";
|
||||||
|
return database.executeUniqueQuery(OrderDetail.class, query)
|
||||||
|
.orElseThrow(() -> new PizzaBotException("Aktuell läuft keine Pizzabestellung."));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Pair<@NotNull Restaurant, @NotNull List<@NotNull Pizza>> getRestaurantWithPizzas(long restaurantId) throws SQLException {
|
||||||
|
return database.transaction(() -> {
|
||||||
|
var restaurant = database.findById(Restaurant.class, restaurantId).orElseThrow();
|
||||||
|
var pizzas = database.findAllWhere(Pizza.class, Pizza.Fields.restaurantId, restaurantId);
|
||||||
|
return Pair.of(restaurant, pizzas);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment
|
||||||
|
|
||||||
|
public void save(@NotNull Order order) throws SQLException {
|
||||||
|
database.update(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull User getUserByName(@NotNull String name) throws SQLException {
|
||||||
|
try {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = """
|
||||||
|
SELECT "user".* FROM "user"
|
||||||
|
WHERE "user"."username" LIKE ?
|
||||||
|
""";
|
||||||
|
return database.executeUniqueQuery(User.class, query, stmt -> stmt.setString(1, name + "%"))
|
||||||
|
.orElseThrow(() -> new PizzaBotException("Der Benutzer existiert nicht."));
|
||||||
|
} catch (NonUniqueResultException _) {
|
||||||
|
throw new PizzaBotException("Der Benutzer ist nicht eindeutig.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull User getUserByEventAndName(long eventId, @NotNull String name) throws SQLException {
|
||||||
|
try {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = """
|
||||||
|
SELECT "user".* FROM "user"
|
||||||
|
WHERE "user"."username" LIKE ?
|
||||||
|
AND EXISTS(
|
||||||
|
SELECT * FROM "order_item_to_user"
|
||||||
|
JOIN "order_item" ON "order_item_to_user"."order_item_id" = "order_item"."id"
|
||||||
|
JOIN "order" ON "order_item"."order_id" = "order"."id"
|
||||||
|
WHERE "order_item_to_user"."user_id" = "user"."id"
|
||||||
|
AND "order"."event_id" = ?
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
return database.executeUniqueQuery(User.class, query, stmt -> {
|
||||||
|
stmt.setString(1, name + "%");
|
||||||
|
stmt.setLong(2, eventId);
|
||||||
|
}).orElseThrow(() -> new PizzaBotException("Der Benutzer existiert nicht oder hat an keiner Pizzabestellung teilgenommen."));
|
||||||
|
} catch (NonUniqueResultException _) {
|
||||||
|
throw new PizzaBotException("Der Benutzer ist nicht eindeutig.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void paymentByItem(long itemId, boolean payed) throws SQLException {
|
||||||
|
database.executeUpdate("UPDATE order_item_to_user SET payed = ? WHERE order_item_id = ?", stmt -> {
|
||||||
|
stmt.setBoolean(1, payed);
|
||||||
|
stmt.setLong(2, itemId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public int paymentByEventAndUser(long eventId, long userId, boolean payed) throws SQLException {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = """
|
||||||
|
UPDATE "order_item_to_user"
|
||||||
|
SET "payed" = ?
|
||||||
|
WHERE "order_item_to_user"."user_id" = ?
|
||||||
|
AND EXISTS(
|
||||||
|
SELECT *
|
||||||
|
FROM "order_item"
|
||||||
|
JOIN "order" ON "order_item"."order_id" = "order"."id"
|
||||||
|
WHERE "order"."event_id" = ? AND "order_item"."id" = "order_item_to_user"."order_item_id"
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
return database.executeUpdate(query, stmt -> {
|
||||||
|
stmt.setBoolean(1, payed);
|
||||||
|
stmt.setLong(2, userId);
|
||||||
|
stmt.setLong(3, eventId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void paymentByItemAndUser(long itemId, long userId, boolean payed) throws SQLException {
|
||||||
|
database.executeUpdate("UPDATE order_item_to_user SET payed = ? WHERE order_item_id = ? AND user_id = ?", stmt -> {
|
||||||
|
stmt.setBoolean(1, payed);
|
||||||
|
stmt.setLong(2, itemId);
|
||||||
|
stmt.setLong(3, userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order
|
||||||
|
|
||||||
|
public @NotNull Optional<Pizza> getPizzaById(long id) throws SQLException {
|
||||||
|
return database.findById(Pizza.class, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Pizza getPizza(long restaurantId, @NotNull String nameOrNummer) throws SQLException {
|
||||||
|
try {
|
||||||
|
var nummer = Integer.parseInt(nameOrNummer);
|
||||||
|
return getPizzaByNummer(restaurantId, nummer);
|
||||||
|
} catch (NumberFormatException _) {
|
||||||
|
return getPizzaByName(restaurantId, nameOrNummer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private @NotNull Pizza getPizzaByNummer(long restaurantId, int nummer) throws SQLException {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = "SELECT * FROM pizza WHERE restaurant_id = ? AND number = ?";
|
||||||
|
return database.executeUniqueQuery(Pizza.class, query, stmt -> {
|
||||||
|
stmt.setLong(1, restaurantId);
|
||||||
|
stmt.setInt(2, nummer);
|
||||||
|
}).orElseThrow(() -> new PizzaBotException("Diese Pizza kenne ich nicht."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private @NotNull Pizza getPizzaByName(long restaurantId, @NotNull String name) throws SQLException {
|
||||||
|
try {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = "SELECT * FROM pizza WHERE restaurant_id = ? AND name LIKE ?";
|
||||||
|
return database.executeUniqueQuery(Pizza.class, query, stmt -> {
|
||||||
|
stmt.setLong(1, restaurantId);
|
||||||
|
stmt.setString(2, name + "%");
|
||||||
|
}).orElseThrow(() -> new PizzaBotException("Diese Pizza kenne ich nicht."));
|
||||||
|
} catch (NonUniqueResultException _) {
|
||||||
|
throw new PizzaBotException("Diese Pizza kenne ich nicht.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long order(@NotNull OrderDetail order, @NotNull User user, @NotNull Pizza pizza, @Nullable String notes) throws SQLException {
|
||||||
|
return database.transaction(() -> {
|
||||||
|
try {
|
||||||
|
database.insert(user);
|
||||||
|
} catch (SQLException _) {}
|
||||||
|
|
||||||
|
var item = new OrderItem(-1, order.id(), pizza.id(), notes);
|
||||||
|
var itemId = database.insert(item);
|
||||||
|
|
||||||
|
var mapping = new OrderItemToUser(itemId, user.id(), false);
|
||||||
|
database.insert(mapping);
|
||||||
|
|
||||||
|
return itemId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void add(@NotNull OrderItem item, @NotNull User user, boolean payed) throws SQLException {
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), payed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull List<@NotNull UserWithPayment> getUsersByItem(@NotNull OrderItem item) throws SQLException {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = """
|
||||||
|
SELECT
|
||||||
|
"user"."id" AS "id",
|
||||||
|
"user"."username" AS "username",
|
||||||
|
"order_item_to_user"."order_item_id" AS "order_item_id",
|
||||||
|
"order_item_to_user"."payed" AS "payed"
|
||||||
|
FROM "user"
|
||||||
|
JOIN "order_item_to_user" ON "user"."id" = "order_item_to_user"."user_id"
|
||||||
|
WHERE "order_item_to_user"."order_item_id" = ?
|
||||||
|
""";
|
||||||
|
return database.executeQuery(UserWithPayment.class, query, stmt -> stmt.setLong(1, item.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke
|
||||||
|
|
||||||
|
public @NotNull Optional<OrderItemSummary> getItemById(long itemId) throws SQLException {
|
||||||
|
return database.transaction(() -> {
|
||||||
|
var item = database.findById(OrderItem.class, itemId);
|
||||||
|
if (item.isEmpty()) return Optional.empty();
|
||||||
|
|
||||||
|
var pizza = database.findById(Pizza.class, item.get().pizzaId()).orElseThrow();
|
||||||
|
var users = getUsersByItem(item.get());
|
||||||
|
|
||||||
|
return Optional.of(new OrderItemSummary(item.get(), pizza, users));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Optional<OrderItem> getItemByPizzaName(long orderId, long userId, @NotNull String name) throws SQLException {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = """
|
||||||
|
SELECT "order_item".*
|
||||||
|
FROM "order_item"
|
||||||
|
JOIN "pizza" ON "order_item"."pizza_id" = "pizza"."id"
|
||||||
|
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
|
||||||
|
WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ? AND "pizza"."name" LIKE ?
|
||||||
|
""";
|
||||||
|
return database.executeUniqueQuery(OrderItem.class, query, stmt -> {
|
||||||
|
stmt.setLong(1, orderId);
|
||||||
|
stmt.setLong(2, userId);
|
||||||
|
stmt.setString(3, name + "%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Optional<OrderItem> getItemByUser(long orderId, long userId) throws SQLException {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = """
|
||||||
|
SELECT "order_item".*
|
||||||
|
FROM "order_item"
|
||||||
|
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
|
||||||
|
WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ?
|
||||||
|
""";
|
||||||
|
return database.executeUniqueQuery(OrderItem.class, query, stmt -> {
|
||||||
|
stmt.setLong(1, orderId);
|
||||||
|
stmt.setLong(2, userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull List<@NotNull OrderItem> getItemsByUser(long orderId, long userId) throws SQLException {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = """
|
||||||
|
SELECT "order_item".*
|
||||||
|
FROM "order_item"
|
||||||
|
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
|
||||||
|
WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ?
|
||||||
|
""";
|
||||||
|
return database.executeQuery(OrderItem.class, query, stmt -> {
|
||||||
|
stmt.setLong(1, orderId);
|
||||||
|
stmt.setLong(2, userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean delete(@NotNull OrderItem item) throws SQLException {
|
||||||
|
return database.delete(OrderItem.class, item.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
|
||||||
|
public @NotNull Order getOrderByEventAndName(long eventId, @NotNull String name) throws SQLException {
|
||||||
|
try {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = """
|
||||||
|
SELECT "order".*
|
||||||
|
FROM "order"
|
||||||
|
WHERE "order"."event_id" = ? AND "order"."name" LIKE ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
return database.executeUniqueQuery(Order.class, query, stmt -> {
|
||||||
|
stmt.setLong(1, eventId);
|
||||||
|
stmt.setString(2, name + "%");
|
||||||
|
}).orElseThrow(() -> new PizzaBotException("Diese Bestellung kenne ich nicht."));
|
||||||
|
} catch (NonUniqueResultException ex) {
|
||||||
|
throw new PizzaBotException("Die Bestellung ist nicht eindeutig.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order List
|
||||||
|
|
||||||
|
public @NotNull List<@NotNull OrderItemDetail> getItemDetailByEvent(long eventId) throws SQLException {
|
||||||
|
return database.findAllWhere(OrderItemDetail.class, OrderItemDetail.Fields.eventId, eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull List<@NotNull OrderItemDetail> getItemDetailByOrder(long orderId) throws SQLException {
|
||||||
|
return database.findAllWhere(OrderItemDetail.class, OrderItemDetail.Fields.orderId, orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull List<@NotNull OrderItemDetail> getItemDetailByEventAndUser(long eventId, long userId) throws SQLException {
|
||||||
|
@Language("SQL")
|
||||||
|
var query = """
|
||||||
|
SELECT *
|
||||||
|
FROM order_item_detail
|
||||||
|
WHERE event_id = ? AND EXISTS(
|
||||||
|
SELECT * FROM "order_item_to_user"
|
||||||
|
WHERE "user_id" = ? AND "order_item_id" = "order_item_detail"."id"
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
return database.executeQuery(OrderItemDetail.class, query, stmt -> {
|
||||||
|
stmt.setLong(1, eventId);
|
||||||
|
stmt.setLong(2, userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.PizzaBotException;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.OrderItem;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.Pizza;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.User;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record OrderItemSummary(@NotNull OrderItem item, @NotNull Pizza pizza, @NotNull List<UserWithPayment> users) {
|
||||||
|
public @NotNull OrderItemSummary checkUserAccess(@NotNull User user) {
|
||||||
|
if (users.stream().noneMatch(u -> u.id() == user.id())) {
|
||||||
|
throw new PizzaBotException("Du bist nicht berechtigt, diese Bestellung zu bearbeiten.");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model;
|
||||||
|
|
||||||
|
import lombok.experimental.FieldNameConstants;
|
||||||
|
|
||||||
|
@FieldNameConstants
|
||||||
|
public record OrderItemToUser(long orderItemId, long userId, long parts, boolean payed) {
|
||||||
|
public OrderItemToUser(long orderItemId, long userId, boolean payed) {
|
||||||
|
this(orderItemId, userId, 1, payed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderItemToUser withPayed(boolean payed) {
|
||||||
|
return new OrderItemToUser(orderItemId, userId, parts, payed);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular;
|
||||||
|
|
||||||
|
public record Receipt(double total, double tipped, double pending, double pendingTipped) {
|
||||||
|
public Receipt(double total, boolean payed, double tip) {
|
||||||
|
this(total, total * tip, payed ? 0 : total, payed ? 0 : total * tip);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean payed() {
|
||||||
|
return pending == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean unpayed() {
|
||||||
|
return total == pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull String totalAsString() {
|
||||||
|
return tabular(total, 0) + " €" + (tipped != 0 ? " (" + tabular(tipped, 0) + " €)" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull String pendingAsString() {
|
||||||
|
return tabular(pending, 0) + " €" + (pendingTipped != 0 ? " (" + tabular(pendingTipped, 0) + " €)" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Receipt empty() {
|
||||||
|
return new Receipt(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Receipt sum(@NotNull Receipt first, @NotNull Receipt second) {
|
||||||
|
return new Receipt(
|
||||||
|
first.total + second.total,
|
||||||
|
first.tipped + second.tipped,
|
||||||
|
first.pending + second.pending,
|
||||||
|
first.pendingTipped + second.pendingTipped
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
public record UserWithPayment(long id, @NotNull String username, long orderItemId, boolean payed) {
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model.table;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.database.Entity;
|
||||||
|
import lombok.experimental.FieldNameConstants;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
@FieldNameConstants
|
||||||
|
public record Event(long id, @NotNull String name, long adminId, boolean active) implements Entity<Event> {
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model.table;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.database.Entity;
|
||||||
|
import lombok.experimental.FieldNameConstants;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@FieldNameConstants
|
||||||
|
public record Order(
|
||||||
|
long id,
|
||||||
|
long eventId,
|
||||||
|
long restaurantId,
|
||||||
|
@NotNull String name,
|
||||||
|
@NotNull Instant deadline,
|
||||||
|
@Nullable Double payment
|
||||||
|
) implements Entity<Order>, Comparable<Order> {
|
||||||
|
|
||||||
|
public Order {
|
||||||
|
Objects.requireNonNull(deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Order withPayment(@Nullable Double payment) {
|
||||||
|
return new Order(id, eventId, restaurantId, name, deadline, payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int compareTo(@NotNull Order o) {
|
||||||
|
return this.deadline.compareTo(o.deadline);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model.table;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.database.Entity;
|
||||||
|
import lombok.experimental.FieldNameConstants;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
@FieldNameConstants
|
||||||
|
public record OrderItem(
|
||||||
|
long id,
|
||||||
|
long orderId,
|
||||||
|
long pizzaId,
|
||||||
|
@Nullable String notes
|
||||||
|
) implements Entity<OrderItem> {
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model.table;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.database.Entity;
|
||||||
|
import lombok.experimental.FieldNameConstants;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular;
|
||||||
|
|
||||||
|
@FieldNameConstants
|
||||||
|
public record Pizza(
|
||||||
|
long id,
|
||||||
|
long restaurantId,
|
||||||
|
int number,
|
||||||
|
@NotNull String name,
|
||||||
|
@NotNull String ingredients,
|
||||||
|
double price
|
||||||
|
) implements Entity<Pizza>, Comparable<Pizza> {
|
||||||
|
|
||||||
|
public @NotNull String toPrettyString() {
|
||||||
|
var number = tabular(number());
|
||||||
|
var price = tabular(price(), 5);
|
||||||
|
var name = name().toUpperCase(Locale.ROOT);
|
||||||
|
var ingredients = ingredients();
|
||||||
|
return STR."\{number} | \{price} € | \{name} \{ingredients}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int compareTo(@NotNull Pizza o) {
|
||||||
|
return Integer.compare(number, o.number);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model.table;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.database.Entity;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record Restaurant(
|
||||||
|
long id,
|
||||||
|
@NotNull String name,
|
||||||
|
@NotNull String city,
|
||||||
|
@Nullable String phone,
|
||||||
|
@Nullable String notes
|
||||||
|
) implements Entity<Restaurant> {
|
||||||
|
public Restaurant {
|
||||||
|
Objects.requireNonNull(name);
|
||||||
|
Objects.requireNonNull(city);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model.table;
|
||||||
|
|
||||||
|
import lombok.experimental.FieldNameConstants;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@FieldNameConstants
|
||||||
|
public record User(long id, @NotNull String username) {
|
||||||
|
public User {
|
||||||
|
Objects.requireNonNull(username);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model.view;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.PizzaBotException;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.Order;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.User;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record OrderDetail(
|
||||||
|
// Order
|
||||||
|
long id,
|
||||||
|
@NotNull String name,
|
||||||
|
@NotNull Instant deadline,
|
||||||
|
@Nullable Double payment,
|
||||||
|
// Event
|
||||||
|
long eventId,
|
||||||
|
@NotNull String eventName,
|
||||||
|
long adminId,
|
||||||
|
// Restaurant
|
||||||
|
long restaurantId,
|
||||||
|
@NotNull String restaurantName
|
||||||
|
) {
|
||||||
|
public @NotNull Order order() {
|
||||||
|
return new Order(id, eventId, restaurantId, name, deadline, payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull OrderDetail checkDeadline() {
|
||||||
|
if (Instant.now().isAfter(deadline())) {
|
||||||
|
throw new PizzaBotException("Du kannst diese Bestellung nicht mehr bearbeiten, da der Bestellannahmeschluss schon vorbei ist.");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull OrderDetail checkAdminAccess(@NotNull User user) {
|
||||||
|
if (adminId() != user.id()) {
|
||||||
|
throw new PizzaBotException("Du bist nicht berechtigt, diese Aktion durchzuführen.");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,79 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.model.view;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.Receipt;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.Order;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.Pizza;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.User;
|
||||||
|
import lombok.experimental.FieldNameConstants;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.strikethrough;
|
||||||
|
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular;
|
||||||
|
|
||||||
|
@FieldNameConstants
|
||||||
|
public record OrderItemDetail(
|
||||||
|
// OrderItem
|
||||||
|
long id,
|
||||||
|
@Nullable String notes,
|
||||||
|
// Order
|
||||||
|
long orderId,
|
||||||
|
@NotNull String orderName,
|
||||||
|
@NotNull Instant orderDeadline,
|
||||||
|
@Nullable Double orderPayment,
|
||||||
|
// Event
|
||||||
|
long eventId,
|
||||||
|
// Restaurant
|
||||||
|
long restaurantId,
|
||||||
|
@NotNull String restaurantName,
|
||||||
|
// Pizza
|
||||||
|
long pizzaId,
|
||||||
|
int pizzaNumber,
|
||||||
|
@NotNull String pizzaName,
|
||||||
|
double pizzaPrice,
|
||||||
|
// User
|
||||||
|
long userId,
|
||||||
|
@NotNull String userName,
|
||||||
|
long userParts,
|
||||||
|
boolean userPayed,
|
||||||
|
long userCount
|
||||||
|
) {
|
||||||
|
public @NotNull Order order() {
|
||||||
|
return new Order(orderId, eventId, restaurantId, orderName, orderDeadline, orderPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Pizza pizza() {
|
||||||
|
return new Pizza(pizzaId, restaurantId, pizzaNumber, pizzaName, "N/A", pizzaPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull User user() {
|
||||||
|
return new User(userId, userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Receipt receipt(double tip) {
|
||||||
|
return new Receipt(userPrice(), userPayed(), tip);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double userPrice() {
|
||||||
|
return userParts() * pizzaPrice() / userCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull String toPrettyString() {
|
||||||
|
String price;
|
||||||
|
if (userCount() == 1) {
|
||||||
|
price = tabular(pizzaPrice(), 0) + " €";
|
||||||
|
} else {
|
||||||
|
price = tabular(pizzaPrice(), 0) + " € / " + tabular(userCount(), 0) + " = " + tabular(userPrice(), 0) + " €";
|
||||||
|
}
|
||||||
|
if (userPayed()) price = strikethrough(price);
|
||||||
|
|
||||||
|
if (notes() != null) {
|
||||||
|
return STR."\{id()} | \{pizzaName().toUpperCase(Locale.ROOT)} \{notes()} | \{price}";
|
||||||
|
} else {
|
||||||
|
return STR."\{id()} | \{pizzaName().toUpperCase(Locale.ROOT)} | \{price}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.util;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.PizzaBotException;
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@UtilityClass
|
||||||
|
public class ArgumentParser {
|
||||||
|
public static @NotNull List<@NotNull String> parse(@NotNull String string) {
|
||||||
|
var list = new ArrayList<String>();
|
||||||
|
var quote = (char) 0;
|
||||||
|
var escaped = false;
|
||||||
|
|
||||||
|
var current = new StringBuilder();
|
||||||
|
var it = string.chars().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
var chr = (char) it.nextInt();
|
||||||
|
if (escaped) {
|
||||||
|
if (chr != '\n') current.append(chr);
|
||||||
|
} else if (chr == '\\' && quote != '\'') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (quote == 0 && chr == '"') {
|
||||||
|
quote = '"';
|
||||||
|
} else if (quote == 0 && chr == '\'') {
|
||||||
|
quote = '\'';
|
||||||
|
} else if (quote == chr) {
|
||||||
|
quote = 0;
|
||||||
|
} else if (chr == ' ' || chr == '\t') {
|
||||||
|
if (!current.isEmpty()) {
|
||||||
|
list.add(current.toString());
|
||||||
|
current.setLength(0);
|
||||||
|
}
|
||||||
|
} else if (chr == '\n') {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
current.append(chr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
throw new IllegalArgumentException("incomplete escape sequence");
|
||||||
|
} else if (quote != 0) {
|
||||||
|
throw new IllegalArgumentException("incomplete quote");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.isEmpty()) {
|
||||||
|
list.add(current.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Collections.unmodifiableList(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isLong(@NotNull String string) {
|
||||||
|
try {
|
||||||
|
Long.parseLong(string);
|
||||||
|
return true;
|
||||||
|
} catch (NumberFormatException _) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long toLong(@NotNull String string) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(string);
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
throw new PizzaBotException(string + " ist keine gültige Zahl.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isDouble(@NotNull String string) {
|
||||||
|
try {
|
||||||
|
Double.parseDouble(string);
|
||||||
|
return true;
|
||||||
|
} catch (NumberFormatException _) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double toDouble(@NotNull String string) {
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(string);
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
throw new PizzaBotException(string + " ist keine gültige Zahl.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,81 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.util;
|
||||||
|
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.TestOnly;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@UtilityClass
|
||||||
|
public class FormatUtils {
|
||||||
|
public static @NotNull String tabular(long number) {
|
||||||
|
return tabular(number, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull String tabular(long number, int width) {
|
||||||
|
return tabular(String.format(Locale.ROOT, "%" + (width == 0 ? "" : width) + "d", number));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull String tabular(double number) {
|
||||||
|
return tabular(number, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull String tabular(double number, int width) {
|
||||||
|
return tabular(String.format(Locale.ROOT, "%" + (width == 0 ? "" : width) + ".2f", number));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @NotNull String tabular(@NotNull String string) {
|
||||||
|
var out = new StringBuilder();
|
||||||
|
for (int i = 0, length = string.length(); i < length; i++) {
|
||||||
|
var chr = string.charAt(i);
|
||||||
|
if (chr == ' ') {
|
||||||
|
out.append('\u2007'); // figure space
|
||||||
|
} else {
|
||||||
|
out.append('\u2060'); // word-joiner to prevent kerning
|
||||||
|
out.append(chr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("UnnecessaryUnicodeEscape")
|
||||||
|
public static @NotNull String strikethrough(@NotNull String string) {
|
||||||
|
var out = new StringBuilder();
|
||||||
|
for (int i = 0, length = string.length(); i < length; i++) {
|
||||||
|
out.append(string.charAt(i)).append('\u0336');
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestOnly
|
||||||
|
@SuppressWarnings("UnnecessaryUnicodeEscape")
|
||||||
|
public static @NotNull String strip(@NotNull String string) {
|
||||||
|
var out = new StringBuilder();
|
||||||
|
for (int i = 0, length = string.length(); i < length; i++) {
|
||||||
|
var chr = string.charAt(i);
|
||||||
|
if (chr != '\u0336' && chr != '\u0335' && chr != '\u2007' && chr != '\u2060') {
|
||||||
|
out.append(chr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull String join(@NotNull List<@NotNull String> strings) {
|
||||||
|
return switch (strings.size()) {
|
||||||
|
case 0 -> "";
|
||||||
|
case 1 -> strings.getFirst();
|
||||||
|
default -> String.join(", ", strings.subList(0, strings.size() - 1)) + " und " + strings.getLast();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> @NotNull String join(@NotNull List<@NotNull T> items, @NotNull Function<? super T, String> toString) {
|
||||||
|
return join(items.stream().map(toString).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> @NotNull String join(@NotNull String separator, @NotNull List<@NotNull T> items, @NotNull Function<? super T, String> toString) {
|
||||||
|
return items.stream().map(toString).collect(Collectors.joining(separator));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza.util;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
public record Pair<F, S>(F first, S second) {
|
||||||
|
public static <F, S> @NotNull Pair<F, S> of(F first, S second) {
|
||||||
|
return new Pair<>(first, second);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
import eu.jonahbauer.chat.bot.api.ChatBot;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.PizzaBot;
|
||||||
|
|
||||||
|
module eu.jonahbauer.chat.bot.pizza {
|
||||||
|
exports eu.jonahbauer.chat.bot.pizza.model.table to eu.jonahbauer.chat.bot.database;
|
||||||
|
exports eu.jonahbauer.chat.bot.pizza.model.view to eu.jonahbauer.chat.bot.database;
|
||||||
|
exports eu.jonahbauer.chat.bot.pizza.model to eu.jonahbauer.chat.bot.database;
|
||||||
|
|
||||||
|
requires eu.jonahbauer.chat.bot.api;
|
||||||
|
requires eu.jonahbauer.chat.bot.database;
|
||||||
|
|
||||||
|
requires org.slf4j;
|
||||||
|
requires static lombok;
|
||||||
|
|
||||||
|
provides ChatBot with PizzaBot;
|
||||||
|
}
|
@ -0,0 +1,115 @@
|
|||||||
|
CREATE TABLE "user"
|
||||||
|
(
|
||||||
|
"id" INTEGER PRIMARY KEY,
|
||||||
|
"username" VARCHAR(255) NOT NULL,
|
||||||
|
CONSTRAINT "uq_username" UNIQUE ("username")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "restaurant"
|
||||||
|
(
|
||||||
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"name" VARCHAR(255) NOT NULL,
|
||||||
|
"city" VARCHAR(255) NOT NULL,
|
||||||
|
"phone" VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
"notes" VARCHAR(255) NULL DEFAULT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
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" VARCHAR(255) NOT NULL,
|
||||||
|
CONSTRAINT "fk_pizza_restaurant" FOREIGN KEY ("restaurant_id") REFERENCES "restaurant" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uq_pizza_number" UNIQUE ("restaurant_id", "number")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "event"
|
||||||
|
(
|
||||||
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"name" VARCHAR(255) NOT NULL,
|
||||||
|
"admin_id" INTEGER NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
CONSTRAINT "fk_event_admin" FOREIGN KEY ("admin_id") REFERENCES "user" ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "uq_event_active" ON "event" ("active")
|
||||||
|
WHERE "active" = TRUE;
|
||||||
|
|
||||||
|
CREATE TABLE "order"
|
||||||
|
(
|
||||||
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"event_id" INTEGER NOT NULL,
|
||||||
|
"restaurant_id" INTEGER NOT NULL,
|
||||||
|
"name" VARCHAR(255) NOT NULL,
|
||||||
|
"deadline" TIMESTAMP NOT NULL,
|
||||||
|
"payment" DOUBLE NULL,
|
||||||
|
CONSTRAINT "fk_order_event" FOREIGN KEY ("event_id") REFERENCES "event" ("id"),
|
||||||
|
CONSTRAINT "fk_order_restaurant" FOREIGN KEY ("restaurant_id") REFERENCES "restaurant" ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "order_item"
|
||||||
|
(
|
||||||
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"order_id" INTEGER NOT NULL,
|
||||||
|
"pizza_id" INTEGER NOT NULL,
|
||||||
|
"notes" VARCHAR(255),
|
||||||
|
CONSTRAINT "fk_order_item_order" FOREIGN KEY ("order_id") REFERENCES "order" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "fk_order_item_pizza" FOREIGN KEY ("pizza_id") REFERENCES "pizza" ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "order_item_to_user"
|
||||||
|
(
|
||||||
|
"order_item_id" INTEGER NOT NULL,
|
||||||
|
"user_id" INTEGER NOT NULL,
|
||||||
|
"parts" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"payed" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
PRIMARY KEY ("order_item_id", "user_id"),
|
||||||
|
CONSTRAINT "fk_order_item_to_user_order_item" FOREIGN KEY ("order_item_id") REFERENCES "order_item" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "fk_order_item_to_user_user_id" FOREIGN KEY ("user_id") REFERENCES "user" ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIEW "order_item_detail"
|
||||||
|
AS
|
||||||
|
SELECT "order_item"."id",
|
||||||
|
"order_item"."notes",
|
||||||
|
"order"."id" AS "order_id",
|
||||||
|
"order"."name" AS "order_name",
|
||||||
|
"order"."deadline" AS "order_deadline",
|
||||||
|
"order"."payment" AS "order_payment",
|
||||||
|
"order"."event_id" AS "event_id",
|
||||||
|
"restaurant"."id" AS "restaurant_id",
|
||||||
|
"restaurant"."name" AS "restaurant_name",
|
||||||
|
"pizza"."id" AS "pizza_id",
|
||||||
|
"pizza"."number" AS "pizza_number",
|
||||||
|
"pizza"."name" AS "pizza_name",
|
||||||
|
"pizza"."price" AS "pizza_price",
|
||||||
|
"order_item_to_user"."user_id" AS "user_id",
|
||||||
|
"order_item_to_user"."payed" AS "user_payed",
|
||||||
|
"order_item_to_user"."parts" AS "user_parts",
|
||||||
|
"user"."username" AS "user_name",
|
||||||
|
(SELECT SUM("parts") FROM "order_item_to_user" WHERE "order_item_id" = "order_item"."id") AS "user_count"
|
||||||
|
FROM "order"
|
||||||
|
JOIN "restaurant" ON "order"."restaurant_id" = "restaurant"."id"
|
||||||
|
JOIN "order_item" ON "order"."id" = "order_item"."order_id"
|
||||||
|
JOIN "pizza" ON "order_item"."pizza_id" = "pizza"."id"
|
||||||
|
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
|
||||||
|
JOIN "user" ON "order_item_to_user"."user_id" = "user"."id";
|
||||||
|
|
||||||
|
CREATE VIEW "order_detail"
|
||||||
|
AS
|
||||||
|
SELECT "order"."id",
|
||||||
|
"order"."name",
|
||||||
|
"order"."deadline",
|
||||||
|
"order"."payment",
|
||||||
|
"event"."id" AS "event_id",
|
||||||
|
"event"."name" AS "event_name",
|
||||||
|
"event"."admin_id",
|
||||||
|
"event"."active",
|
||||||
|
"restaurant"."id" AS "restaurant_id",
|
||||||
|
"restaurant"."name" AS "restaurant_name"
|
||||||
|
FROM "event"
|
||||||
|
JOIN "order" ON "event"."id" = "order"."event_id"
|
||||||
|
JOIN "restaurant" ON "order"."restaurant_id" = "restaurant"."id";
|
@ -0,0 +1,38 @@
|
|||||||
|
INSERT INTO "restaurant"("name", "city", "phone", "notes")
|
||||||
|
VALUES ('Hell''s Pizza', 'Sonthofen', '08321 7881930', '2023');
|
||||||
|
|
||||||
|
WITH temp(id) AS (SELECT last_insert_rowid())
|
||||||
|
INSERT INTO "pizza"("restaurant_id", "number", "name", "ingredients", "price")
|
||||||
|
SELECT * FROM temp CROSS JOIN (
|
||||||
|
VALUES (10, 'Pizzabrot', 'mit Tomaten & Knoblauch', 6.50),
|
||||||
|
(11, 'Margherita', 'mit Tomaten, Mozzarella & Basilikum', 9.50),
|
||||||
|
(12, 'Salami', 'mit Tomaten, Mozzarella & Salami (gegen Aufpreis mit Rindersalami)', 10.00),
|
||||||
|
(13, 'Prosciutto', 'mit Tomaten, Mozzarella & Schinken', 10.00),
|
||||||
|
(14, 'Salsiccia', 'mit Tomaten, Mozzarella & scharfer Salami', 9.50),
|
||||||
|
(15, 'Funghi', 'mit Tomaten, Mozzarella & Champignons', 9.50),
|
||||||
|
(16, 'Romana', 'mit Tomaten, Mozzarella, Salami & Champignons', 10.50),
|
||||||
|
(17, 'Hawaii', 'mit Tomaten, Mozzarella, Schinken & Ananas', 10.50),
|
||||||
|
(18, 'Regina', 'mit Tomaten, Mozzarella, Schinken & Champignons', 10.50),
|
||||||
|
(19, 'Toscana', 'mit Tomaten, Mozzarella, Salami, Champignons & Peperoni', 11.00),
|
||||||
|
(20, 'Tonno', 'mit Tomaten, Mozzarella, Thunfisch, Zwiebeln & Oliven', 11.00),
|
||||||
|
(21, 'Quattro Formaggi', 'mit Tomaten & vier Käsesorten', 10.50),
|
||||||
|
(22, 'Calzone', 'mit Tomaten, Mozzarella, Schinken & Champignons', 10.50),
|
||||||
|
(24, 'Diavolo', 'mit Tomaten, Mozzarella, scharfer Salami, Peperoni & Oliven', 11.00),
|
||||||
|
(25, 'Rucoletta', 'mit Tomaten, Mozzarella, Rucola, Tomatenstücken & Parmesan', 11.00),
|
||||||
|
(26, 'Quattro Stagioni', 'mit Tomaten, Mozzarella, Schinken, Salami, Champignons, Artischocken, Sardellen & Oliven', 11.00),
|
||||||
|
(27, 'Vegana', 'mit Tomaten & verschiedenem Gemüse', 10.50),
|
||||||
|
(28, 'Siciliana', 'mit Tomaten, Mozzarella, Peperoni, Kapern, Sardellen, Oliven & Knoblauch', 10.00),
|
||||||
|
(29, 'Rustica', 'mit Tomaten, Mozzarella, Speck, Champignons, Gorgonzola & Knoblauch', 11.00),
|
||||||
|
(30, 'Caprese', 'mit Tomaten, Mozzarella, Tomatenscheiben & Basilikum', 11.00),
|
||||||
|
(31, 'Calabria', 'mit Tomaten, Mozzarella, scharfer Salami, Zwiebeln & original Schafskäse', 12.00),
|
||||||
|
(32, 'Verdura', 'mit Tomaten, Mozzarella, gegrilltem Gemüse & Knoblauch', 12.00),
|
||||||
|
(33, 'Mare', 'mit Tomaten, Meeresfrüchten & Knoblauch', 13.00),
|
||||||
|
(35, 'Mafiosa', 'mit Tomaten, Mozzarella, Speck, scharfer Salami, Champignons, Spinat & Peperoni', 12.00),
|
||||||
|
(36, 'Reale', 'mit Tomaten, Mozzarella, original Parmaschinken, Rucola & Parmesan', 13.90),
|
||||||
|
(38, 'Mediterranea', 'mit Tomatenscheiben, Mozzarella, Thunfisch, Kapern & Rucola', 12.00),
|
||||||
|
(39, 'Tricolore', 'mit Büffel-Mozzarella, Tomatenscheiben & Rucola-Pesto', 13.00),
|
||||||
|
(40, 'Alfio Spezial', 'mit Mozzarella, Tomatenstücken, Salsiccia, Zwiebeln, Parmesan, Oliven & Basilikum', 12.00),
|
||||||
|
(41, 'Allgäuer', 'mit Tomaten, Mozzarella, Speck, Bergkäse, Röstzwiebeln & Knoblauch', 11.50),
|
||||||
|
(42, 'Buon Gustaio', 'mit Tomaten, Mozzarella, Spinat, Gorgonzola & Knoblauch', 11.00),
|
||||||
|
(44, 'Mista', 'mit Tomaten, Mozzarella, Schinken, Salami, Pilzen & Thunfisch', 11.00)
|
||||||
|
);
|
@ -0,0 +1,46 @@
|
|||||||
|
-- Hell's Pizza Sonthofen
|
||||||
|
-- Stand: Oktober 2023
|
||||||
|
INSERT INTO "restaurant"("name", "city", "phone", "notes")
|
||||||
|
VALUES ('Hell''s Pizza', 'Sonthofen', '08321 7881930', '2024');
|
||||||
|
|
||||||
|
WITH temp(id) AS (SELECT last_insert_rowid())
|
||||||
|
INSERT INTO "pizza"("restaurant_id", "number", "name", "ingredients", "price")
|
||||||
|
SELECT * FROM temp CROSS JOIN (
|
||||||
|
VALUES (10, 'Pizzabrot', 'mit Tomaten & Knoblauch', 7.00),
|
||||||
|
(11, 'Margherita', 'mit Tomaten, Mozzarella & Basilikum', 10.00),
|
||||||
|
(12, 'Salami', 'mit Tomaten, Mozzarella & Salami (gegen Aufpreis mit Rindersalami)', 10.50),
|
||||||
|
(13, 'Prosciutto', 'mit Tomaten, Mozzarella & Schinken', 10.50),
|
||||||
|
(14, 'Salsiccia', 'mit Tomaten, Mozzarella & scharfer Salami', 10.50),
|
||||||
|
(15, 'Funghi', 'mit Tomaten, Mozzarella & Champignons', 10.00),
|
||||||
|
(16, 'Romana', 'mit Tomaten, Mozzarella, Salami & Champignons', 11.00),
|
||||||
|
(17, 'Hawaii', 'mit Tomaten, Mozzarella, Schinken & Ananas', 11.00),
|
||||||
|
(18, 'Regina', 'mit Tomaten, Mozzarella, Schinken & Champignons', 11.00),
|
||||||
|
(19, 'Toscana', 'mit Tomaten, Mozzarella, Salami, Champignons & Peperoni', 11.50),
|
||||||
|
(20, 'Tonno', 'mit Tomaten, Mozzarella, Thunfisch, Zwiebeln & Oliven', 11.50),
|
||||||
|
(21, 'Quattro Formaggi', 'mit Tomaten & vier Käsesorten', 11.00),
|
||||||
|
(22, 'Calzone', 'mit Tomaten, Mozzarella, Schinken & Champignons', 11.00),
|
||||||
|
(23, 'Capriccio', 'mit Tomaten, Mozzarella, Schinken, Champignons, Artischocken, Eier', 13.00),
|
||||||
|
(24, 'Diavolo', 'mit Tomaten, Mozzarella, scharfer Salami, Peperoni & Oliven', 11.50),
|
||||||
|
(25, 'Rucoletta', 'mit Tomaten, Mozzarella, Rucola, Tomatenstücken & Parmesan', 12.00),
|
||||||
|
(26, 'Quattro Stagioni', 'mit Tomaten, Mozzarella, Schinken, Salami, Champignons, Artischocken, Sardellen & Oliven', 12.90),
|
||||||
|
(27, 'Vegana', 'mit Tomaten & verschiedenem Gemüse', 11.00),
|
||||||
|
(28, 'Siciliana', 'mit Tomaten, Mozzarella, Peperoni, Kapern, Sardellen, Oliven & Knoblauch', 10.50),
|
||||||
|
(29, 'Rustica', 'mit Tomaten, Mozzarella, Speck, Champignons, Gorgonzola & Knoblauch', 11.50),
|
||||||
|
(30, 'Caprese', 'mit Tomaten, Mozzarella, Tomatenscheiben & Basilikum', 11.50),
|
||||||
|
(31, 'Calabria', 'mit Tomaten, Mozzarella, scharfer Salami, Zwiebeln & original Schafskäse', 12.00),
|
||||||
|
(32, 'Verdura', 'mit Tomaten, Mozzarella, gegrilltem Gemüse & Knoblauch', 12.00),
|
||||||
|
(33, 'Mare', 'mit Tomaten, Meeresfrüchten & Knoblauch', 13.50),
|
||||||
|
(34, 'Contadina', 'mit Büffelmozzarella, Parmaschinken Julienne, Champignons & Rucola', 13.90),
|
||||||
|
(35, 'Mafiosa', 'mit Tomaten, Mozzarella, Speck, scharfer Salami, Champignons, Spinat & Peperoni', 12.90),
|
||||||
|
(36, 'Reale', 'mit Tomaten, Mozzarella, original Parmaschinken, Rucola & Parmesan', 14.90),
|
||||||
|
(38, 'Mediterranea', 'mit Tomatenscheiben, Mozzarella, Thunfisch, Kapern & Rucola', 12.90),
|
||||||
|
(39, 'Tricolore', 'mit Büffel-Mozzarella, Tomatenscheiben & Rucola-Pesto', 13.90),
|
||||||
|
(40, 'Alfio Spezial', 'mit Mozzarella, Tomatenstücken, Salsiccia, Zwiebeln, Parmesan, Oliven & Basilikum', 12.90),
|
||||||
|
(41, 'Allgäuer', 'mit Tomaten, Mozzarella, Speck, Bergkäse, Röstzwiebeln & Knoblauch DAS ORIGINAL', 12.90),
|
||||||
|
(42, 'Buon Gustaio', 'mit Tomaten, Mozzarella, Spinat, Gorgonzola & Knoblauch', 11.90),
|
||||||
|
(44, 'Mista', 'mit Tomaten, Mozzarella, Schinken, Salami, Pilzen & Thunfisch', 11.90),
|
||||||
|
(48, 'Cudduruni', 'Tomatenstücken, Zwiebeln, Parmesan, Oliven, Basilikum, Chili-Olivenöl NEU', 13.90),
|
||||||
|
(49, 'Bufalina', 'mit Tomaten, Büffelmozzarella, frischer Basilikum und Olivenöl NEU', 14.90)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
@ -0,0 +1,629 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza;
|
||||||
|
|
||||||
|
import eu.jonahbauer.chat.bot.api.Message.Post;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.*;
|
||||||
|
import eu.jonahbauer.chat.bot.pizza.model.table.*;
|
||||||
|
import eu.jonahbauer.chat.bot.test.MockChat;
|
||||||
|
import eu.jonahbauer.chat.bot.database.Database;
|
||||||
|
import eu.jonahbauer.chat.bot.test.ChatBotFactoryAccess;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.*;
|
||||||
|
import static java.util.FormatProcessor.FMT;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class PizzaBotTest {
|
||||||
|
@TempDir
|
||||||
|
static Path temp;
|
||||||
|
|
||||||
|
Database database;
|
||||||
|
MockChat chat;
|
||||||
|
PizzaBot bot;
|
||||||
|
|
||||||
|
User admin;
|
||||||
|
User user;
|
||||||
|
User user2;
|
||||||
|
|
||||||
|
Event event;
|
||||||
|
Order order0; // alte bestellung (bestellannahmeschluss vorbei)
|
||||||
|
Order order1; // alte bestellung
|
||||||
|
Order order; // laufende bestellung
|
||||||
|
|
||||||
|
Pizza salami;
|
||||||
|
Pizza margherita;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void init() throws IOException, SQLException {
|
||||||
|
var file = Files.createTempFile(temp, "database", ".db");
|
||||||
|
database = new Database("jdbc:sqlite:" + file.toAbsolutePath());
|
||||||
|
run("init.sql");
|
||||||
|
run("restaurants/sonthofen_2023.sql");
|
||||||
|
|
||||||
|
chat = new MockChat();
|
||||||
|
bot = ChatBotFactoryAccess.create(() -> new PizzaBot(Collections.singleton("marek"), database));
|
||||||
|
|
||||||
|
admin = database.save(new User(0, "Admin"));
|
||||||
|
user = database.save(new User(1337, "User1"));
|
||||||
|
user2 = database.save(new User(42, "User2"));
|
||||||
|
|
||||||
|
event = database.save(new Event(0, "Veranstaltung", admin.id(), true));
|
||||||
|
order0 = database.save(new Order(0, event.id(), 1, "28.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() - 3_600_000), 90.0));
|
||||||
|
order1 = database.save(new Order(0, event.id(), 1, "29.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() + 3_600_000), null));
|
||||||
|
order = database.save(new Order(0, event.id(), 1, "30.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() + 7_200_000), null));
|
||||||
|
|
||||||
|
salami = database.findWhere(Pizza.class, Pizza.Fields.name, "Salami").orElseThrow();
|
||||||
|
margherita = database.findWhere(Pizza.class, Pizza.Fields.name, "Margherita").orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void print() {
|
||||||
|
for (var message : chat.getMessages()) {
|
||||||
|
System.out.println("\n" + message.name() + "\n" + message.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void run(@NotNull String script) throws SQLException, IOException {
|
||||||
|
try (var conn = database.getConnection()) {
|
||||||
|
var runner = new ScriptRunner(conn, false, true);
|
||||||
|
runner.setLogWriter(null);
|
||||||
|
try (var stream = Objects.requireNonNull(PizzaBot.class.getResourceAsStream(script))) {
|
||||||
|
try (var reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
|
||||||
|
runner.runScript(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deadline() throws SQLException {
|
||||||
|
database.delete(order);
|
||||||
|
database.delete(order1);
|
||||||
|
order = order0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void post(@NotNull User user, @NotNull String message) {
|
||||||
|
var post = new Post(0, user.username(), message, "marek", LocalDateTime.now(), 0L, user.id(), user.username(), "ffffff", 0);
|
||||||
|
bot.onMessage(chat, post);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class OrderPizza {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderByPizzaName() throws SQLException {
|
||||||
|
post(user, "!marek order SALAMI");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(1, items.size());
|
||||||
|
|
||||||
|
var item = items.getFirst();
|
||||||
|
assertNull(item.notes());
|
||||||
|
assertEquals(order.id(), item.orderId());
|
||||||
|
assertEquals(salami.id(), item.pizzaId());
|
||||||
|
|
||||||
|
var mappings = database.findAll(OrderItemToUser.class);
|
||||||
|
assertEquals(List.of(new OrderItemToUser(item.id(), user.id(), false)), mappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderByPizzaNameWithNotes() throws SQLException {
|
||||||
|
post(user, "!marek order SALAMI Hello World");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(1, items.size());
|
||||||
|
|
||||||
|
var item = items.getFirst();
|
||||||
|
assertEquals("Hello World", item.notes());
|
||||||
|
assertEquals(order.id(), item.orderId());
|
||||||
|
assertEquals(salami.id(), item.pizzaId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderByPizzaNameFailsAfterDeadline() throws SQLException {
|
||||||
|
deadline();
|
||||||
|
post(user, "!marek order SALAMI");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(0, items.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderByPizzaNumber() throws SQLException {
|
||||||
|
post(user, "!marek order 13");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(1, items.size());
|
||||||
|
|
||||||
|
var item = items.getFirst();
|
||||||
|
assertNull(item.notes());
|
||||||
|
assertEquals(order.id(), item.orderId());
|
||||||
|
|
||||||
|
var pizza = database.findById(Pizza.class, item.pizzaId());
|
||||||
|
assertTrue(pizza.isPresent());
|
||||||
|
assertEquals("Prosciutto", pizza.get().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderAddById() throws SQLException {
|
||||||
|
var item = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(admin, STR."!marek order add \{item.id()} \{user2.username()}");
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of(
|
||||||
|
new OrderItemToUser(item.id(), user.id(), false),
|
||||||
|
new OrderItemToUser(item.id(), user2.id(), false)
|
||||||
|
),
|
||||||
|
database.findAll(OrderItemToUser.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderAddByName() throws SQLException {
|
||||||
|
var item = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(admin, STR."!marek order add \{user.username()} \{user2.username()}");
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of(
|
||||||
|
new OrderItemToUser(item.id(), user.id(), false),
|
||||||
|
new OrderItemToUser(item.id(), user2.id(), false)
|
||||||
|
),
|
||||||
|
database.findAll(OrderItemToUser.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderAddByNameFailsWhenNotUnique() throws SQLException {
|
||||||
|
var item1 = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
|
||||||
|
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(admin, STR."!marek order add \{user.username()} \{user2.username()}");
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of(
|
||||||
|
new OrderItemToUser(item1.id(), user.id(), false),
|
||||||
|
new OrderItemToUser(item2.id(), user.id(), false)
|
||||||
|
),
|
||||||
|
database.findAll(OrderItemToUser.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderAddByIdFailsWhenAlreadyPayed() throws SQLException {
|
||||||
|
var item = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user2.id(), true));
|
||||||
|
|
||||||
|
post(admin, STR."!marek order add \{item.id()} \{admin.username()}");
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of(
|
||||||
|
new OrderItemToUser(item.id(), user.id(), false),
|
||||||
|
new OrderItemToUser(item.id(), user2.id(), true)
|
||||||
|
),
|
||||||
|
database.findAll(OrderItemToUser.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class Revoke {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevoke() throws SQLException {
|
||||||
|
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
|
||||||
|
var item2 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item2.id(), user2.id(), false));
|
||||||
|
|
||||||
|
post(user, "!marek order revoke");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(List.of(item2), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevokeFailsAfterDeadline() throws SQLException {
|
||||||
|
deadline();
|
||||||
|
|
||||||
|
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(user, "!marek order revoke");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(List.of(item), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevokeFailsWithMultipleItems() throws SQLException {
|
||||||
|
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
|
||||||
|
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(user, "!marek order revoke");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(2, items.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevokeFailsWhenItemBelongsToOtherOrder() throws SQLException {
|
||||||
|
var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(user, "!marek order revoke");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(List.of(item), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevokeByNameWithMultipleItems() throws SQLException {
|
||||||
|
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
|
||||||
|
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(user, "!marek order revoke SALAMI");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(List.of(item1), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevokeByNameFailsWhenItemBelongsToOtherUser() throws SQLException {
|
||||||
|
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(user2, "!marek order revoke MARGHERITA");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(List.of(item), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevokeByNameFailsWhenItemBelongsToOtherOrder() throws SQLException {
|
||||||
|
var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(user, "!marek order revoke MARGHERITA");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(List.of(item), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevokeById() throws SQLException {
|
||||||
|
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(user, "!marek order revoke " + item.id());
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(List.of(), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevokeByIdFailsWhenItemBelongsToOtherUser() throws SQLException {
|
||||||
|
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(user2, "!marek order revoke " + item.id());
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(List.of(item), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevokeByIdFailsWhenItemBelongsToOtherOrder() throws SQLException {
|
||||||
|
var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
|
||||||
|
post(user, "!marek order revoke " + item.id());
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(List.of(item), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderRevokeAll() throws SQLException {
|
||||||
|
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
|
||||||
|
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
|
||||||
|
var item3 = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
database.insert(new OrderItemToUser(item3.id(), user2.id(), false));
|
||||||
|
|
||||||
|
post(user, "!marek order revoke --all");
|
||||||
|
|
||||||
|
var items = database.findAll(OrderItem.class);
|
||||||
|
assertEquals(List.of(item3), items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class Payment {
|
||||||
|
OrderItem item;
|
||||||
|
OrderItemToUser itemToUser;
|
||||||
|
OrderItemToUser itemToUser2;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void init() throws SQLException {
|
||||||
|
item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
itemToUser = database.save(new OrderItemToUser(item.id(), user.id(), false));
|
||||||
|
itemToUser2 = database.save(new OrderItemToUser(item.id(), user2.id(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paymentConfirmById() throws SQLException {
|
||||||
|
post(admin, "!marek payment confirm " + item.id());
|
||||||
|
|
||||||
|
var mappings = database.findAll(OrderItemToUser.class);
|
||||||
|
assertEquals(List.of(itemToUser.withPayed(true), itemToUser2.withPayed(true)), mappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paymentConfirmByIdFailsWhenNotAdmin() throws SQLException {
|
||||||
|
post(user, "!marek payment confirm " + item.id());
|
||||||
|
|
||||||
|
var mappings = database.findAll(OrderItemToUser.class);
|
||||||
|
assertEquals(List.of(itemToUser, itemToUser2), mappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paymentConfirmByIdAndUser() throws SQLException {
|
||||||
|
post(admin, "!marek payment confirm " + item.id() + " " + user.username());
|
||||||
|
|
||||||
|
var mappings = database.findAll(OrderItemToUser.class);
|
||||||
|
assertEquals(List.of(itemToUser.withPayed(true), itemToUser2), mappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paymentConfirmByIdAndUserFailsWhenNotAdmin() throws SQLException {
|
||||||
|
post(user, "!marek payment confirm " + item.id() + " " + user.username());
|
||||||
|
|
||||||
|
var mappings = database.findAll(OrderItemToUser.class);
|
||||||
|
assertEquals(List.of(itemToUser, itemToUser2), mappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paymentConfirmByUser() throws SQLException {
|
||||||
|
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
var item2ToUser = database.save(new OrderItemToUser(item2.id(), user.id(), false));
|
||||||
|
var item2ToUser2 = database.save(new OrderItemToUser(item2.id(), user2.id(), false));
|
||||||
|
|
||||||
|
post(admin, "!marek payment confirm --all " + user.username());
|
||||||
|
|
||||||
|
var mappings = database.findAll(OrderItemToUser.class);
|
||||||
|
assertEquals(
|
||||||
|
List.of(itemToUser.withPayed(true), itemToUser2, item2ToUser.withPayed(true), item2ToUser2),
|
||||||
|
mappings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class Pay {
|
||||||
|
@Test
|
||||||
|
void pay() throws SQLException {
|
||||||
|
post(admin, "!marek pay 42.5");
|
||||||
|
|
||||||
|
var order = database.findById(Order.class, PizzaBotTest.this.order.id());
|
||||||
|
assertTrue(order.isPresent());
|
||||||
|
assertEquals(42.5, order.get().payment());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void payFailsWhenNotAdmin() throws SQLException {
|
||||||
|
post(user, "!marek pay 42.5");
|
||||||
|
|
||||||
|
var order = database.findById(Order.class, PizzaBotTest.this.order.id());
|
||||||
|
assertTrue(order.isPresent());
|
||||||
|
assertEquals(PizzaBotTest.this.order, order.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void payWithOrderName() throws SQLException {
|
||||||
|
post(admin, "!marek pay " + order0.name() + " 42.5");
|
||||||
|
|
||||||
|
var order0 = database.findById(Order.class, PizzaBotTest.this.order0.id());
|
||||||
|
assertTrue(order0.isPresent());
|
||||||
|
assertEquals(42.5, order0.get().payment());
|
||||||
|
|
||||||
|
var order = database.findById(Order.class, PizzaBotTest.this.order.id());
|
||||||
|
assertTrue(order.isPresent());
|
||||||
|
assertNull(order.get().payment());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class CheckAndSummary {
|
||||||
|
OrderItem item1;
|
||||||
|
OrderItemToUser item1ToUser;
|
||||||
|
|
||||||
|
OrderItem item2;
|
||||||
|
OrderItemToUser item2ToUser;
|
||||||
|
|
||||||
|
OrderItem item3;
|
||||||
|
OrderItemToUser item3ToUser;
|
||||||
|
|
||||||
|
OrderItem item4;
|
||||||
|
OrderItemToUser item4ToUser2;
|
||||||
|
|
||||||
|
OrderItem item5;
|
||||||
|
OrderItemToUser item5ToUser;
|
||||||
|
OrderItemToUser item5ToUser2;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void init() throws SQLException {
|
||||||
|
item1 = database.save(new OrderItem(0, order0.id(), margherita.id(), null));
|
||||||
|
item1ToUser = database.save(new OrderItemToUser(item1.id(), user.id(), false));
|
||||||
|
|
||||||
|
item2 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
item2ToUser = database.save(new OrderItemToUser(item2.id(), user.id(), false));
|
||||||
|
|
||||||
|
item3 = database.save(new OrderItem(0, order.id(), salami.id(), null));
|
||||||
|
item3ToUser = database.save(new OrderItemToUser(item3.id(), user.id(), false));
|
||||||
|
|
||||||
|
item4 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
item4ToUser2 = database.save(new OrderItemToUser(item4.id(), user2.id(), false));
|
||||||
|
|
||||||
|
item5 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
|
||||||
|
item5ToUser = database.save(new OrderItemToUser(item5.id(), user.id(), false));
|
||||||
|
item5ToUser2 = database.save(new OrderItemToUser(item5.id(), user2.id(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void check() {
|
||||||
|
post(admin, "!marek check");
|
||||||
|
var message = chat.getMessages().getFirst();
|
||||||
|
var expected = FMT."""
|
||||||
|
Rechnung für %s\{order.name()}
|
||||||
|
alle Angaben ohne Gewähr
|
||||||
|
|
||||||
|
%s\{user.username()}: %.2f\{1.5 * margherita.price() + salami.price()} €
|
||||||
|
%s\{user2.username()}: %.2f\{1.5 * margherita.price()} €
|
||||||
|
|
||||||
|
Gesamt: %.2f\{3 * margherita.price() + salami.price()} €
|
||||||
|
""".trim();
|
||||||
|
assertEquals(expected, strip(message.message()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkTip() throws SQLException {
|
||||||
|
database.update(order.withPayment(40d));
|
||||||
|
|
||||||
|
post(admin, "!marek check");
|
||||||
|
var message = chat.getMessages().getFirst();
|
||||||
|
var expected = FMT."""
|
||||||
|
Rechnung für %s\{order.name()} (mit Trinkgeld)
|
||||||
|
alle Angaben ohne Gewähr
|
||||||
|
|
||||||
|
%s\{user.username()}: %.2f\{1.5 * margherita.price() + salami.price()} € (25.19 €)
|
||||||
|
%s\{user2.username()}: %.2f\{1.5 * margherita.price()} € (14.81 €)
|
||||||
|
|
||||||
|
Gesamt: %.2f\{3 * margherita.price() + salami.price()} € (40.00 €)
|
||||||
|
""".trim();
|
||||||
|
assertEquals(expected, strip(message.message()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkPayed() throws SQLException {
|
||||||
|
database.executeUpdate("UPDATE order_item_to_user SET payed = TRUE WHERE user_id = " + user.id());
|
||||||
|
|
||||||
|
post(admin, "!marek check");
|
||||||
|
var message = chat.getMessages().getFirst();
|
||||||
|
var expected = FMT."""
|
||||||
|
Rechnung für %s\{order.name()}
|
||||||
|
alle Angaben ohne Gewähr
|
||||||
|
|
||||||
|
%s\{user.username()}: %.2f\{1.5 * margherita.price() + salami.price()} €
|
||||||
|
%s\{user2.username()}: %.2f\{1.5 * margherita.price()} €
|
||||||
|
|
||||||
|
Gesamt: %.2f\{3 * margherita.price() + salami.price()} € %.2f\{1.5 * margherita.price()} €
|
||||||
|
""".trim();
|
||||||
|
assertEquals(expected, strip(message.message()));
|
||||||
|
assertEquals(strikethrough("User1: " + tabular(1.5 * margherita.price() + salami.price(), 0) + " €"), message.message().split("\n")[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkPartialPayed() throws SQLException {
|
||||||
|
database.executeUpdate("UPDATE order_item_to_user SET payed = TRUE WHERE order_item_id = " + item5.id());
|
||||||
|
|
||||||
|
var user1Total = 1.5 * margherita.price() + salami.price();
|
||||||
|
var user1Remaining = margherita.price() + salami.price();
|
||||||
|
var user2Total = 1.5 * margherita.price();
|
||||||
|
var user2Remaining = margherita.price();
|
||||||
|
|
||||||
|
post(admin, "!marek check");
|
||||||
|
var message = chat.getMessages().getFirst();
|
||||||
|
var expected = FMT."""
|
||||||
|
Rechnung für %s\{order.name()}
|
||||||
|
alle Angaben ohne Gewähr
|
||||||
|
|
||||||
|
%s\{user.username()}: %.2f\{user1Total} € %.2f\{user1Remaining} €
|
||||||
|
%s\{user2.username()}: %.2f\{user2Total} € %.2f\{user2Remaining} €
|
||||||
|
|
||||||
|
Gesamt: %.2f\{user1Total + user2Total} € %.2f\{user1Remaining + user2Remaining} €
|
||||||
|
""".trim();
|
||||||
|
assertEquals(expected, strip(message.message()));
|
||||||
|
assertEquals(
|
||||||
|
STR."\{user.username()}: \{strikethrough(tabular(user1Total, 0)+ " €")} \{tabular(user1Remaining, 0)} €",
|
||||||
|
message.message().split("\n")[3]
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
STR."\{user2.username()}: \{strikethrough(tabular(user2Total, 0)+ " €")} \{tabular(user2Remaining, 0)} €",
|
||||||
|
message.message().split("\n")[4]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkAllTip() throws SQLException {
|
||||||
|
database.update(order.withPayment(40d));
|
||||||
|
|
||||||
|
post(admin, "!marek check --all");
|
||||||
|
var message = chat.getMessages().getFirst();
|
||||||
|
var expected = FMT."""
|
||||||
|
Rechnung für %s\{event.name()} (mit Trinkgeld)
|
||||||
|
alle Angaben ohne Gewähr
|
||||||
|
|
||||||
|
%s\{user.username()}: %.2f\{2.5 * margherita.price() + salami.price()} € (115.19 €)
|
||||||
|
%s\{user2.username()}: %.2f\{1.5 * margherita.price()} € (14.81 €)
|
||||||
|
|
||||||
|
Gesamt: %.2f\{4 * margherita.price() + salami.price()} € (130.00 €)
|
||||||
|
""".trim();
|
||||||
|
assertEquals(expected, strip(message.message()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void summary() {
|
||||||
|
post(admin, "!marek summary");
|
||||||
|
var message = chat.getMessages().getFirst();
|
||||||
|
var expected = FMT."""
|
||||||
|
Übersicht %s\{order.name()}
|
||||||
|
|
||||||
|
3x \{margherita.name().toUpperCase(Locale.ROOT)} (\{user.username()}, \{user.username()}+\{user2.username()}, \{user2.username()})
|
||||||
|
1x \{salami.name().toUpperCase(Locale.ROOT)} (\{user.username()})
|
||||||
|
""".trim();
|
||||||
|
assertEquals(expected, strip(message.message()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void summaryAll() {
|
||||||
|
post(admin, "!marek summary --all");
|
||||||
|
var message = chat.getMessages().getFirst();
|
||||||
|
var expected = FMT."""
|
||||||
|
Übersicht %s\{event.name()}
|
||||||
|
|
||||||
|
4x \{margherita.name().toUpperCase(Locale.ROOT)} (2x \{user.username()}, \{user.username()}+\{user2.username()}, \{user2.username()})
|
||||||
|
1x \{salami.name().toUpperCase(Locale.ROOT)} (\{user.username()})
|
||||||
|
""".trim();
|
||||||
|
assertEquals(expected, strip(message.message()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,248 @@
|
|||||||
|
package eu.jonahbauer.chat.bot.pizza;
|
||||||
|
/*
|
||||||
|
* Slightly modified version of the com.ibatis.common.jdbc.ScriptRunner class
|
||||||
|
* from the iBATIS Apache project. Only removed dependency on Resource class
|
||||||
|
* and a constructor
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* Copyright 2004 Clinton Begin
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.LineNumberReader;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.sql.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool to run database scripts
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("all")
|
||||||
|
public class ScriptRunner {
|
||||||
|
|
||||||
|
private static final String DEFAULT_DELIMITER = ";";
|
||||||
|
|
||||||
|
private Connection connection;
|
||||||
|
|
||||||
|
private boolean stopOnError;
|
||||||
|
private boolean autoCommit;
|
||||||
|
|
||||||
|
private PrintWriter logWriter = new PrintWriter(System.out);
|
||||||
|
private PrintWriter errorLogWriter = new PrintWriter(System.err);
|
||||||
|
|
||||||
|
private String delimiter = DEFAULT_DELIMITER;
|
||||||
|
private boolean fullLineDelimiter = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor
|
||||||
|
*/
|
||||||
|
public ScriptRunner(Connection connection, boolean autoCommit,
|
||||||
|
boolean stopOnError) {
|
||||||
|
this.connection = connection;
|
||||||
|
this.autoCommit = autoCommit;
|
||||||
|
this.stopOnError = stopOnError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDelimiter(String delimiter, boolean fullLineDelimiter) {
|
||||||
|
this.delimiter = delimiter;
|
||||||
|
this.fullLineDelimiter = fullLineDelimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for logWriter property
|
||||||
|
*
|
||||||
|
* @param logWriter
|
||||||
|
* - the new value of the logWriter property
|
||||||
|
*/
|
||||||
|
public void setLogWriter(PrintWriter logWriter) {
|
||||||
|
this.logWriter = logWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for errorLogWriter property
|
||||||
|
*
|
||||||
|
* @param errorLogWriter
|
||||||
|
* - the new value of the errorLogWriter property
|
||||||
|
*/
|
||||||
|
public void setErrorLogWriter(PrintWriter errorLogWriter) {
|
||||||
|
this.errorLogWriter = errorLogWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an SQL script (read in using the Reader parameter)
|
||||||
|
*
|
||||||
|
* @param reader
|
||||||
|
* - the source of the script
|
||||||
|
*/
|
||||||
|
public void runScript(Reader reader) throws IOException, SQLException {
|
||||||
|
try {
|
||||||
|
boolean originalAutoCommit = connection.getAutoCommit();
|
||||||
|
try {
|
||||||
|
if (originalAutoCommit != this.autoCommit) {
|
||||||
|
connection.setAutoCommit(this.autoCommit);
|
||||||
|
}
|
||||||
|
runScript(connection, reader);
|
||||||
|
} finally {
|
||||||
|
connection.setAutoCommit(originalAutoCommit);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Error running script. Cause: " + e, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an SQL script (read in using the Reader parameter) using the
|
||||||
|
* connection passed in
|
||||||
|
*
|
||||||
|
* @param conn
|
||||||
|
* - the connection to use for the script
|
||||||
|
* @param reader
|
||||||
|
* - the source of the script
|
||||||
|
* @throws SQLException
|
||||||
|
* if any SQL errors occur
|
||||||
|
* @throws IOException
|
||||||
|
* if there is an error reading from the Reader
|
||||||
|
*/
|
||||||
|
private void runScript(Connection conn, Reader reader) throws IOException,
|
||||||
|
SQLException {
|
||||||
|
StringBuffer command = null;
|
||||||
|
try {
|
||||||
|
LineNumberReader lineReader = new LineNumberReader(reader);
|
||||||
|
String line = null;
|
||||||
|
while ((line = lineReader.readLine()) != null) {
|
||||||
|
if (command == null) {
|
||||||
|
command = new StringBuffer();
|
||||||
|
}
|
||||||
|
String trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.startsWith("--")) {
|
||||||
|
println(trimmedLine);
|
||||||
|
} else if (trimmedLine.length() < 1
|
||||||
|
|| trimmedLine.startsWith("//")) {
|
||||||
|
// Do nothing
|
||||||
|
} else if (trimmedLine.length() < 1
|
||||||
|
|| trimmedLine.startsWith("--")) {
|
||||||
|
// Do nothing
|
||||||
|
} else if (!fullLineDelimiter
|
||||||
|
&& trimmedLine.endsWith(getDelimiter())
|
||||||
|
|| fullLineDelimiter
|
||||||
|
&& trimmedLine.equals(getDelimiter())) {
|
||||||
|
command.append(line.substring(0, line
|
||||||
|
.lastIndexOf(getDelimiter())));
|
||||||
|
command.append(" ");
|
||||||
|
Statement statement = conn.createStatement();
|
||||||
|
|
||||||
|
println(command);
|
||||||
|
|
||||||
|
boolean hasResults = false;
|
||||||
|
if (stopOnError) {
|
||||||
|
hasResults = statement.execute(command.toString());
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
statement.execute(command.toString());
|
||||||
|
} catch (SQLException e) {
|
||||||
|
e.fillInStackTrace();
|
||||||
|
printlnError("Error executing: " + command);
|
||||||
|
printlnError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoCommit && !conn.getAutoCommit()) {
|
||||||
|
conn.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
ResultSet rs = statement.getResultSet();
|
||||||
|
if (hasResults && rs != null) {
|
||||||
|
ResultSetMetaData md = rs.getMetaData();
|
||||||
|
int cols = md.getColumnCount();
|
||||||
|
for (int i = 0; i < cols; i++) {
|
||||||
|
String name = md.getColumnLabel(i);
|
||||||
|
print(name + "\t");
|
||||||
|
}
|
||||||
|
println("");
|
||||||
|
while (rs.next()) {
|
||||||
|
for (int i = 0; i < cols; i++) {
|
||||||
|
String value = rs.getString(i);
|
||||||
|
print(value + "\t");
|
||||||
|
}
|
||||||
|
println("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
command = null;
|
||||||
|
try {
|
||||||
|
statement.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Ignore to workaround a bug in Jakarta DBCP
|
||||||
|
}
|
||||||
|
Thread.yield();
|
||||||
|
} else {
|
||||||
|
command.append(line);
|
||||||
|
command.append(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!autoCommit) {
|
||||||
|
conn.commit();
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
e.fillInStackTrace();
|
||||||
|
printlnError("Error executing: " + command);
|
||||||
|
printlnError(e);
|
||||||
|
throw e;
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.fillInStackTrace();
|
||||||
|
printlnError("Error executing: " + command);
|
||||||
|
printlnError(e);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
conn.rollback();
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getDelimiter() {
|
||||||
|
return delimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void print(Object o) {
|
||||||
|
if (logWriter != null) {
|
||||||
|
System.out.print(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void println(Object o) {
|
||||||
|
if (logWriter != null) {
|
||||||
|
logWriter.println(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void printlnError(Object o) {
|
||||||
|
if (errorLogWriter != null) {
|
||||||
|
errorLogWriter.println(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void flush() {
|
||||||
|
if (logWriter != null) {
|
||||||
|
logWriter.flush();
|
||||||
|
}
|
||||||
|
if (errorLogWriter != null) {
|
||||||
|
errorLogWriter.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
id("net.researchgate.release") version "3.0.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
release {
|
||||||
|
failOnUnversionedFiles = false
|
||||||
|
}
|
@ -0,0 +1,68 @@
|
|||||||
|
package eu.jonahbauer.chat.build.jlink
|
||||||
|
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.file.ConfigurableFileCollection
|
||||||
|
import org.gradle.api.file.DirectoryProperty
|
||||||
|
import org.gradle.api.file.RegularFileProperty
|
||||||
|
import org.gradle.api.plugins.JavaPluginExtension
|
||||||
|
import org.gradle.api.plugins.internal.JavaPluginHelper
|
||||||
|
import org.gradle.api.provider.ListProperty
|
||||||
|
import org.gradle.api.tasks.*
|
||||||
|
import org.gradle.api.tasks.internal.JavaExecExecutableUtils
|
||||||
|
import org.gradle.jvm.toolchain.JavaToolchainService
|
||||||
|
import org.gradle.jvm.toolchain.internal.JavaExecutableUtils
|
||||||
|
import org.gradle.kotlin.dsl.getByType
|
||||||
|
|
||||||
|
abstract class JLinkTask : DefaultTask() {
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val output : DirectoryProperty
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
abstract val executable : RegularFileProperty
|
||||||
|
|
||||||
|
@get:InputFiles
|
||||||
|
abstract val modulePath : ConfigurableFileCollection
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val modules : ListProperty<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val options : ListProperty<String>
|
||||||
|
|
||||||
|
init {
|
||||||
|
group = "distribution"
|
||||||
|
description = "assembles a modular runtime image using jlink"
|
||||||
|
|
||||||
|
output.convention(project.layout.buildDirectory.dir("dist/jre"))
|
||||||
|
modules.convention(listOf("ALL-MODULE-PATH"))
|
||||||
|
|
||||||
|
if (project.pluginManager.hasPlugin("java")) {
|
||||||
|
val feature = JavaPluginHelper.getJavaComponent(project).mainFeature
|
||||||
|
modulePath.from(feature.runtimeClasspathConfiguration)
|
||||||
|
modulePath.from(feature.jarTask)
|
||||||
|
|
||||||
|
val toolchains = project.extensions.getByType(JavaToolchainService::class)
|
||||||
|
val java = project.extensions.getByType(JavaPluginExtension::class)
|
||||||
|
|
||||||
|
executable.convention(toolchains.launcherFor(java.toolchain)
|
||||||
|
.map { it.metadata.installationPath }
|
||||||
|
.map { it.dir("bin").file("jlink") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val args = options.get() + listOf(
|
||||||
|
"--module-path", modulePath.asPath,
|
||||||
|
"--output", output.get().asFile.path,
|
||||||
|
"--add-modules", modules.get().joinToString(",")
|
||||||
|
)
|
||||||
|
val executable = executable.get().asFile.path
|
||||||
|
|
||||||
|
project.delete(output)
|
||||||
|
project.exec {
|
||||||
|
this.executable = executable
|
||||||
|
this.args = args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
version=0.2.0-SNAPSHOT
|
@ -1,22 +1,20 @@
|
|||||||
[versions]
|
[versions]
|
||||||
annotations = "24.1.0"
|
annotations = "24.1.0"
|
||||||
jackson = "2.16.1"
|
gson = "2.10.1"
|
||||||
|
hikari = "5.1.0"
|
||||||
junit = "5.10.2"
|
junit = "5.10.2"
|
||||||
log4j2 = "2.22.1"
|
logback = "1.5.3"
|
||||||
lombok = "1.18.30"
|
lombok = "1.18.30"
|
||||||
|
slf4j = "2.0.12"
|
||||||
|
sqlite = "3.44.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" }
|
annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" }
|
||||||
jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson"}
|
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||||
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson"}
|
hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" }
|
||||||
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson"}
|
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||||
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
|
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
|
||||||
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
|
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
|
||||||
log4j2-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2"}
|
|
||||||
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" }
|
lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" }
|
||||||
|
slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
||||||
[bundles]
|
sqlite = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite"}
|
||||||
jackson = ["jackson-databind", "jackson-annotations", "jackson-datatype-jsr310"]
|
|
||||||
log4j2 = ["log4j2-api", "log4j2-core", "log4j2-slf4j"]
|
|
||||||
|
@ -1,26 +1,45 @@
|
|||||||
|
import eu.jonahbauer.chat.build.jlink.JLinkTask
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("chat-bot.java-conventions")
|
id("chat-bot.java-conventions")
|
||||||
application
|
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "eu.jonahbauer.chat"
|
group = "eu.jonahbauer.chat"
|
||||||
version = "0.1.0-SNAPSHOT"
|
|
||||||
|
|
||||||
val bots = project(":bots").subprojects
|
val bot : Configuration by configurations.creating
|
||||||
|
configurations.runtimeOnly {
|
||||||
|
extendsFrom(bot)
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":bot-api"))
|
implementation(project(":bot-api"))
|
||||||
implementation(project(":management"))
|
implementation(project(":management"))
|
||||||
implementation(libs.bundles.log4j2)
|
implementation(libs.gson)
|
||||||
implementation(libs.bundles.jackson)
|
implementation(libs.slf4j)
|
||||||
|
|
||||||
|
runtimeOnly(libs.logback)
|
||||||
|
|
||||||
|
project.project(":bots").subprojects.forEach {
|
||||||
|
bot(project(it.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bots.forEach {
|
tasks.named<JavaCompile>("compileJava") {
|
||||||
implementation(project(it.path))
|
options.javaModuleMainClass = "eu.jonahbauer.chat.server.Main"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.register<JLinkTask>("jlink") {
|
||||||
|
modules = listOf("ALL-MODULE-PATH", "jdk.crypto.ec")
|
||||||
|
options = listOf("--no-header-files", "--no-man-pages", "--strip-debug", "--compress=zip-6")
|
||||||
}
|
}
|
||||||
|
|
||||||
application {
|
tasks.register<Exec>("run") {
|
||||||
mainClass.set("eu.jonahbauer.chat.server.Main")
|
group = "application"
|
||||||
mainModule.set("eu.jonahbauer.chat.server")
|
description = "runs the application using the custom runtime image"
|
||||||
applicationDefaultJvmArgs = listOf("--enable-preview")
|
|
||||||
|
executable = project.layout.buildDirectory.file("dist/jre/bin/java").get().asFile.path
|
||||||
|
args("--enable-preview", "-m", "eu.jonahbauer.chat.server")
|
||||||
|
|
||||||
|
dependsOn(tasks.named<JLinkTask>("jlink"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,30 +1,32 @@
|
|||||||
package eu.jonahbauer.chat.server;
|
package eu.jonahbauer.chat.server;
|
||||||
|
|
||||||
import eu.jonahbauer.chat.server.bot.ChatBotSupervisor;
|
import eu.jonahbauer.chat.server.bot.ChatBotSupervisor;
|
||||||
import eu.jonahbauer.chat.server.management.impl.ChatBotManager;
|
|
||||||
import eu.jonahbauer.chat.server.management.impl.SocketManager;
|
|
||||||
import eu.jonahbauer.chat.server.socket.SocketSupervisor;
|
import eu.jonahbauer.chat.server.socket.SocketSupervisor;
|
||||||
|
import eu.jonahbauer.chat.server.util.Lazy.MutableLazy;
|
||||||
|
|
||||||
|
import javax.management.JMException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
|
||||||
public class Main {
|
public class Main {
|
||||||
|
|
||||||
public static void main(String[] args) throws IOException, InterruptedException {
|
public static void main(String[] args) throws IOException, InterruptedException, JMException {
|
||||||
// initialize ChatBotManager and SocketManager
|
|
||||||
var _ = ChatBotManager.INSTANCE;
|
|
||||||
var _ = SocketManager.INSTANCE;
|
|
||||||
|
|
||||||
var config = Config.load();
|
var config = Config.load();
|
||||||
ChatBotSupervisor.INSTANCE.start(config);
|
var chatBotSupervisorLazy = new MutableLazy<ChatBotSupervisor>();
|
||||||
SocketSupervisor.INSTANCE.start(config);
|
var socketSupervisorLazy = new MutableLazy<SocketSupervisor>();
|
||||||
|
try (
|
||||||
|
var chatBotSupervisor = chatBotSupervisorLazy.set(new ChatBotSupervisor(socketSupervisorLazy));
|
||||||
|
var socketSupervisor = socketSupervisorLazy.set(new SocketSupervisor(chatBotSupervisorLazy))
|
||||||
|
) {
|
||||||
|
chatBotSupervisor.start(config);
|
||||||
|
socketSupervisor.start(config);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// keep main thread running
|
// keep main thread running
|
||||||
new CountDownLatch(1).await();
|
new CountDownLatch(1).await();
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException _) {
|
||||||
SocketSupervisor.INSTANCE.stop();
|
// ignore
|
||||||
ChatBotSupervisor.INSTANCE.stop();
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,61 +0,0 @@
|
|||||||
package eu.jonahbauer.chat.server.bot;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonParseException;
|
|
||||||
import com.fasterxml.jackson.core.JsonParser;
|
|
||||||
import com.fasterxml.jackson.core.JsonToken;
|
|
||||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
|
||||||
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
|
|
||||||
import eu.jonahbauer.chat.bot.config.BotConfig;
|
|
||||||
import eu.jonahbauer.chat.bot.config.BotConfigurationException;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
public class BotConfigDeserializer extends StdDeserializer<BotConfig> {
|
|
||||||
|
|
||||||
protected BotConfigDeserializer() {
|
|
||||||
super(BotConfig.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public BotConfig deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
|
||||||
if (p.currentToken() == JsonToken.START_OBJECT) {
|
|
||||||
p.nextToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
var builder = BotConfig.builder();
|
|
||||||
while (p.currentToken() == JsonToken.FIELD_NAME) {
|
|
||||||
var key = p.currentName();
|
|
||||||
|
|
||||||
switch (p.nextToken()) {
|
|
||||||
case VALUE_STRING -> builder.value(key, p.getValueAsString());
|
|
||||||
case VALUE_FALSE -> builder.value(key, false);
|
|
||||||
case VALUE_TRUE -> builder.value(key, true);
|
|
||||||
case VALUE_NULL -> {}
|
|
||||||
case VALUE_NUMBER_FLOAT -> builder.value(key, p.getDoubleValue());
|
|
||||||
case VALUE_NUMBER_INT -> builder.value(key, p.getLongValue());
|
|
||||||
case START_ARRAY -> {
|
|
||||||
var list = new ArrayList<String>();
|
|
||||||
while (p.nextToken() != JsonToken.END_ARRAY) {
|
|
||||||
if (p.currentToken() == JsonToken.VALUE_STRING) {
|
|
||||||
list.add(p.getValueAsString());
|
|
||||||
} else {
|
|
||||||
throw new BotConfigurationException("Unsupported property type in array.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
builder.value(key, list);
|
|
||||||
}
|
|
||||||
case START_OBJECT -> throw new BotConfigurationException("Unsupported property type: object");
|
|
||||||
default -> throw new BotConfigurationException("Unsupported property property type.");
|
|
||||||
}
|
|
||||||
|
|
||||||
p.nextToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.currentToken() == JsonToken.END_OBJECT) {
|
|
||||||
return builder.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new JsonParseException(p, "Unexpected token: " + p.currentToken(), p.currentLocation());
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,30 @@
|
|||||||
|
package eu.jonahbauer.chat.server.json;
|
||||||
|
|
||||||
|
import com.google.gson.TypeAdapter;
|
||||||
|
import com.google.gson.stream.JsonReader;
|
||||||
|
import com.google.gson.stream.JsonWriter;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class BooleanTypeAdapter extends TypeAdapter<Boolean> {
|
||||||
|
@Override
|
||||||
|
public void write(@NotNull JsonWriter out, @Nullable Boolean value) throws IOException {
|
||||||
|
if (value == null) {
|
||||||
|
out.nullValue();
|
||||||
|
} else {
|
||||||
|
out.value(value ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Boolean read(@NotNull JsonReader in) throws IOException {
|
||||||
|
return switch (in.peek()) {
|
||||||
|
case NUMBER -> in.nextInt() != 0;
|
||||||
|
case BOOLEAN -> in.nextBoolean();
|
||||||
|
case NULL -> null;
|
||||||
|
default -> throw new IllegalStateException("Expected a number but was " + in.peek());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
package eu.jonahbauer.chat.server.json;
|
||||||
|
|
||||||
|
import com.google.gson.*;
|
||||||
|
import eu.jonahbauer.chat.bot.config.BotConfig;
|
||||||
|
import eu.jonahbauer.chat.bot.config.BotConfigurationException;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
|
||||||
|
public class BotConfigDeserializer implements JsonDeserializer<BotConfig> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable BotConfig deserialize(@NotNull JsonElement json, @NotNull Type typeOfT, @NotNull JsonDeserializationContext context) throws JsonParseException {
|
||||||
|
if (json.isJsonNull()) return null;
|
||||||
|
if (!json.isJsonObject()) throw new BotConfigurationException("Expected an object");
|
||||||
|
var obj = json.getAsJsonObject();
|
||||||
|
|
||||||
|
var builder = BotConfig.builder();
|
||||||
|
|
||||||
|
obj.asMap().forEach((key, value) -> {
|
||||||
|
if (value instanceof JsonPrimitive primitive) {
|
||||||
|
if (primitive.isBoolean()) {
|
||||||
|
builder.value(key, primitive.getAsBoolean());
|
||||||
|
} else if (primitive.isString()) {
|
||||||
|
builder.value(key, primitive.getAsString());
|
||||||
|
} else if (primitive.isNumber()) {
|
||||||
|
switch (primitive.getAsNumber()) {
|
||||||
|
case Long l -> builder.value(key, l);
|
||||||
|
case Double d -> builder.value(key, d);
|
||||||
|
case Number n -> throw new BotConfigurationException("Invalid number: " + n);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new AssertionError();
|
||||||
|
}
|
||||||
|
} else if (value.isJsonArray()) {
|
||||||
|
var list = value.getAsJsonArray().asList().stream()
|
||||||
|
.map(e -> {
|
||||||
|
if (e instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||||
|
return primitive.getAsString();
|
||||||
|
} else {
|
||||||
|
throw new BotConfigurationException("Unsupported property type in array.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
builder.value(key, list);
|
||||||
|
} else {
|
||||||
|
throw new BotConfigurationException("Unsupported property type.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
package eu.jonahbauer.chat.server.json;
|
||||||
|
|
||||||
|
import com.google.gson.TypeAdapter;
|
||||||
|
import com.google.gson.stream.JsonReader;
|
||||||
|
import com.google.gson.stream.JsonWriter;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
public class LocalDateTimeTypeAdapter extends TypeAdapter<LocalDateTime> {
|
||||||
|
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(@NotNull JsonWriter out, @Nullable LocalDateTime value) throws IOException {
|
||||||
|
if (value == null) {
|
||||||
|
out.nullValue();
|
||||||
|
} else {
|
||||||
|
out.value(formatter.format(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LocalDateTime read(JsonReader in) throws IOException {
|
||||||
|
return switch (in.peek()) {
|
||||||
|
case NULL -> null;
|
||||||
|
case STRING -> formatter.parse(in.nextString(), LocalDateTime::from);
|
||||||
|
default -> throw new IllegalStateException("Expected a string but was " + in.peek());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,327 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2011 Google Inc.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.jonahbauer.chat.server.json;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import com.google.gson.JsonPrimitive;
|
||||||
|
import com.google.gson.TypeAdapter;
|
||||||
|
import com.google.gson.TypeAdapterFactory;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
import com.google.gson.stream.JsonReader;
|
||||||
|
import com.google.gson.stream.JsonWriter;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapts values whose runtime type may differ from their declaration type. This is necessary when a
|
||||||
|
* field's type is not the same type that GSON should create when deserializing that field. For
|
||||||
|
* example, consider these types:
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* abstract class Shape {
|
||||||
|
* int x;
|
||||||
|
* int y;
|
||||||
|
* }
|
||||||
|
* class Circle extends Shape {
|
||||||
|
* int radius;
|
||||||
|
* }
|
||||||
|
* class Rectangle extends Shape {
|
||||||
|
* int width;
|
||||||
|
* int height;
|
||||||
|
* }
|
||||||
|
* class Diamond extends Shape {
|
||||||
|
* int width;
|
||||||
|
* int height;
|
||||||
|
* }
|
||||||
|
* class Drawing {
|
||||||
|
* Shape bottomShape;
|
||||||
|
* Shape topShape;
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>Without additional type information, the serialized JSON is ambiguous. Is the bottom shape in
|
||||||
|
* this drawing a rectangle or a diamond?
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* {
|
||||||
|
* "bottomShape": {
|
||||||
|
* "width": 10,
|
||||||
|
* "height": 5,
|
||||||
|
* "x": 0,
|
||||||
|
* "y": 0
|
||||||
|
* },
|
||||||
|
* "topShape": {
|
||||||
|
* "radius": 2,
|
||||||
|
* "x": 4,
|
||||||
|
* "y": 1
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* This class addresses this problem by adding type information to the serialized JSON and honoring
|
||||||
|
* that type information when the JSON is deserialized:
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* {
|
||||||
|
* "bottomShape": {
|
||||||
|
* "type": "Diamond",
|
||||||
|
* "width": 10,
|
||||||
|
* "height": 5,
|
||||||
|
* "x": 0,
|
||||||
|
* "y": 0
|
||||||
|
* },
|
||||||
|
* "topShape": {
|
||||||
|
* "type": "Circle",
|
||||||
|
* "radius": 2,
|
||||||
|
* "x": 4,
|
||||||
|
* "y": 1
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* Both the type field name ({@code "type"}) and the type labels ({@code "Rectangle"}) are
|
||||||
|
* configurable.
|
||||||
|
*
|
||||||
|
* <h2>Registering Types</h2>
|
||||||
|
*
|
||||||
|
* Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field name to the
|
||||||
|
* {@link #of} factory method. If you don't supply an explicit type field name, {@code "type"} will
|
||||||
|
* be used.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory
|
||||||
|
* = RuntimeTypeAdapterFactory.of(Shape.class, "type");
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* Next register all of your subtypes. Every subtype must be explicitly registered. This protects
|
||||||
|
* your application from injection attacks. If you don't supply an explicit type label, the type's
|
||||||
|
* simple name will be used.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
|
||||||
|
* shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
|
||||||
|
* shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* Finally, register the type adapter factory in your application's GSON builder:
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* Gson gson = new GsonBuilder()
|
||||||
|
* .registerTypeAdapterFactory(shapeAdapterFactory)
|
||||||
|
* .create();
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* Like {@code GsonBuilder}, this API supports chaining:
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
|
||||||
|
* .registerSubtype(Rectangle.class)
|
||||||
|
* .registerSubtype(Circle.class)
|
||||||
|
* .registerSubtype(Diamond.class);
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <h2>Serialization and deserialization</h2>
|
||||||
|
*
|
||||||
|
* In order to serialize and deserialize a polymorphic object, you must specify the base type
|
||||||
|
* explicitly.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* Diamond diamond = new Diamond();
|
||||||
|
* String json = gson.toJson(diamond, Shape.class);
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* And then:
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* Shape shape = gson.fromJson(json, Shape.class);
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory {
|
||||||
|
private final Class<?> baseType;
|
||||||
|
private final String typeFieldName;
|
||||||
|
private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<>();
|
||||||
|
private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<>();
|
||||||
|
private final boolean maintainType;
|
||||||
|
private boolean recognizeSubtypes;
|
||||||
|
|
||||||
|
private RuntimeTypeAdapterFactory(Class<?> baseType, String typeFieldName, boolean maintainType) {
|
||||||
|
if (typeFieldName == null || baseType == null) {
|
||||||
|
throw new NullPointerException();
|
||||||
|
}
|
||||||
|
this.baseType = baseType;
|
||||||
|
this.typeFieldName = typeFieldName;
|
||||||
|
this.maintainType = maintainType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new runtime type adapter using for {@code baseType} using {@code typeFieldName} as
|
||||||
|
* the type field name. Type field names are case sensitive.
|
||||||
|
*
|
||||||
|
* @param maintainType true if the type field should be included in deserialized objects
|
||||||
|
*/
|
||||||
|
public static <T> RuntimeTypeAdapterFactory<T> of(
|
||||||
|
Class<T> baseType, String typeFieldName, boolean maintainType) {
|
||||||
|
return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, maintainType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new runtime type adapter using for {@code baseType} using {@code typeFieldName} as
|
||||||
|
* the type field name. Type field names are case sensitive.
|
||||||
|
*/
|
||||||
|
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) {
|
||||||
|
return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new runtime type adapter for {@code baseType} using {@code "type"} as the type field
|
||||||
|
* name.
|
||||||
|
*/
|
||||||
|
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) {
|
||||||
|
return new RuntimeTypeAdapterFactory<>(baseType, "type", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that this factory will handle not just the given {@code baseType}, but any subtype of
|
||||||
|
* that type.
|
||||||
|
*/
|
||||||
|
public RuntimeTypeAdapterFactory<T> recognizeSubtypes() {
|
||||||
|
this.recognizeSubtypes = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers {@code type} identified by {@code label}. Labels are case sensitive.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if either {@code type} or {@code label} have already been
|
||||||
|
* registered on this type adapter.
|
||||||
|
*/
|
||||||
|
public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type, String label) {
|
||||||
|
if (type == null || label == null) {
|
||||||
|
throw new NullPointerException();
|
||||||
|
}
|
||||||
|
if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) {
|
||||||
|
throw new IllegalArgumentException("types and labels must be unique");
|
||||||
|
}
|
||||||
|
labelToSubtype.put(label, type);
|
||||||
|
subtypeToLabel.put(type, label);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers {@code type} identified by its {@link Class#getSimpleName simple name}. Labels are
|
||||||
|
* case sensitive.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if either {@code type} or its simple name have already been
|
||||||
|
* registered on this type adapter.
|
||||||
|
*/
|
||||||
|
public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type) {
|
||||||
|
return registerSubtype(type, type.getSimpleName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) {
|
||||||
|
if (type == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Class<?> rawType = type.getRawType();
|
||||||
|
boolean handle =
|
||||||
|
recognizeSubtypes ? baseType.isAssignableFrom(rawType) : baseType.equals(rawType);
|
||||||
|
if (!handle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final TypeAdapter<JsonElement> jsonElementAdapter = gson.getAdapter(JsonElement.class);
|
||||||
|
final Map<String, TypeAdapter<?>> labelToDelegate = new LinkedHashMap<>();
|
||||||
|
final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, Class<?>> entry : labelToSubtype.entrySet()) {
|
||||||
|
TypeAdapter<?> delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue()));
|
||||||
|
labelToDelegate.put(entry.getKey(), delegate);
|
||||||
|
subtypeToDelegate.put(entry.getValue(), delegate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TypeAdapter<R>() {
|
||||||
|
@Override
|
||||||
|
public R read(JsonReader in) throws IOException {
|
||||||
|
JsonElement jsonElement = jsonElementAdapter.read(in);
|
||||||
|
JsonElement labelJsonElement;
|
||||||
|
if (maintainType) {
|
||||||
|
labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName);
|
||||||
|
} else {
|
||||||
|
labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelJsonElement == null) {
|
||||||
|
throw new JsonParseException(
|
||||||
|
"cannot deserialize "
|
||||||
|
+ baseType
|
||||||
|
+ " because it does not define a field named "
|
||||||
|
+ typeFieldName);
|
||||||
|
}
|
||||||
|
String label = labelJsonElement.getAsString();
|
||||||
|
@SuppressWarnings("unchecked") // registration requires that subtype extends T
|
||||||
|
TypeAdapter<R> delegate = (TypeAdapter<R>) labelToDelegate.get(label);
|
||||||
|
if (delegate == null) {
|
||||||
|
throw new JsonParseException(
|
||||||
|
"cannot deserialize "
|
||||||
|
+ baseType
|
||||||
|
+ " subtype named "
|
||||||
|
+ label
|
||||||
|
+ "; did you forget to register a subtype?");
|
||||||
|
}
|
||||||
|
return delegate.fromJsonTree(jsonElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(JsonWriter out, R value) throws IOException {
|
||||||
|
Class<?> srcType = value.getClass();
|
||||||
|
String label = subtypeToLabel.get(srcType);
|
||||||
|
@SuppressWarnings("unchecked") // registration requires that subtype extends T
|
||||||
|
TypeAdapter<R> delegate = (TypeAdapter<R>) subtypeToDelegate.get(srcType);
|
||||||
|
if (delegate == null) {
|
||||||
|
throw new JsonParseException(
|
||||||
|
"cannot serialize " + srcType.getName() + "; did you forget to register a subtype?");
|
||||||
|
}
|
||||||
|
JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject();
|
||||||
|
|
||||||
|
if (maintainType) {
|
||||||
|
jsonElementAdapter.write(out, jsonObject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject clone = new JsonObject();
|
||||||
|
|
||||||
|
if (jsonObject.has(typeFieldName)) {
|
||||||
|
throw new JsonParseException(
|
||||||
|
"cannot serialize "
|
||||||
|
+ srcType.getName()
|
||||||
|
+ " because it already defines a field named "
|
||||||
|
+ typeFieldName);
|
||||||
|
}
|
||||||
|
clone.add(typeFieldName, new JsonPrimitive(label));
|
||||||
|
|
||||||
|
for (Map.Entry<String, JsonElement> e : jsonObject.entrySet()) {
|
||||||
|
clone.add(e.getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
jsonElementAdapter.write(out, clone);
|
||||||
|
}
|
||||||
|
}.nullSafe();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
package eu.jonahbauer.chat.server.util;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public interface Lazy<T> {
|
||||||
|
@NotNull T get();
|
||||||
|
|
||||||
|
class MutableLazy<T> implements Lazy<T> {
|
||||||
|
private T value;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull T get() {
|
||||||
|
var value = this.value;
|
||||||
|
if (value == null) throw new IllegalStateException();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull T set(@NotNull T value) {
|
||||||
|
if (this.value != null) throw new IllegalStateException();
|
||||||
|
this.value = Objects.requireNonNull(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,17 +1,15 @@
|
|||||||
module eu.jonahbauer.chat.server {
|
module eu.jonahbauer.chat.server {
|
||||||
exports eu.jonahbauer.chat.server;
|
exports eu.jonahbauer.chat.server;
|
||||||
opens eu.jonahbauer.chat.server.bot to com.fasterxml.jackson.databind;
|
opens eu.jonahbauer.chat.server.bot to com.google.gson;
|
||||||
opens eu.jonahbauer.chat.server.socket to com.fasterxml.jackson.databind;
|
opens eu.jonahbauer.chat.server.socket to com.google.gson;
|
||||||
|
|
||||||
requires com.fasterxml.jackson.core;
|
requires com.google.gson;
|
||||||
requires com.fasterxml.jackson.databind;
|
|
||||||
requires com.fasterxml.jackson.datatype.jsr310;
|
|
||||||
requires eu.jonahbauer.chat.bot.api;
|
requires eu.jonahbauer.chat.bot.api;
|
||||||
requires eu.jonahbauer.chat.server.management;
|
requires eu.jonahbauer.chat.server.management;
|
||||||
requires java.management;
|
requires java.management;
|
||||||
requires java.net.http;
|
requires java.net.http;
|
||||||
requires org.apache.logging.log4j;
|
requires org.slf4j;
|
||||||
requires org.jetbrains.annotations;
|
|
||||||
|
|
||||||
|
requires static transitive org.jetbrains.annotations;
|
||||||
requires static lombok;
|
requires static lombok;
|
||||||
}
|
}
|
@ -1,12 +0,0 @@
|
|||||||
<Configuration>
|
|
||||||
<Appenders>
|
|
||||||
<Console name="console" target="SYSTEM_OUT">
|
|
||||||
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight{%-5level} [%15.15t] %style{%c{1}}{cyan} : %msg%n" />
|
|
||||||
</Console>
|
|
||||||
</Appenders>
|
|
||||||
<Loggers>
|
|
||||||
<Root level="info" additivity="false">
|
|
||||||
<AppenderRef ref="console" />
|
|
||||||
</Root>
|
|
||||||
</Loggers>
|
|
||||||
</Configuration>
|
|
@ -0,0 +1,11 @@
|
|||||||
|
<configuration>
|
||||||
|
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%15.15t] %cyan(%c{1}) : %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<root level="info">
|
||||||
|
<appender-ref ref="STDOUT"/>
|
||||||
|
</root>
|
||||||
|
</configuration>
|
Loading…
Reference in New Issue