From 95762e9c08670663a1fa0c732b664ad27a8f218c Mon Sep 17 00:00:00 2001 From: Yurii Kucherenko Date: Sat, 13 Jun 2026 11:23:43 +0300 Subject: [PATCH 1/4] feat: migrate from Hibernate JPA to Micronaut Data JDBC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces Hibernate/JPA with Micronaut Data JDBC to reduce JAR size (~85MB of Hibernate/ByteBuddy/JAXB removed) and startup time. Flattens the TABLE_PER_CLASS inheritance + @ElementCollection model into explicit junction tables (budget_account, security_user) with FK-column repos. Also includes: - Docker: Alpine JRE base, BuildKit cache mounts, .dockerignore, v2.0.0 - Micronaut AOT enabled for compile-time bean pre-computation - Schema managed via db/schema.sql (INIT=RUNSCRIPT); schema-generate: NONE - Telegram DefaultBotSession timeout noise suppressed in logback (expected) - @Transactional on ConfigService.setConfig() to make delete+reinsert atomic - Null-safe userId guards in SecurityService and MessageService - Removed broken unused model→entity mapper direction from both mappers - ConfigInitializer uses count() instead of findAll() for existence checks - Diagnostic IllegalStateException messages when config rows are missing --- .dockerignore | 7 + Dockerfile | 15 +- PERF_PLAN.md | 194 ++++++++++++++++++ pom.xml | 20 +- .../bank/repository/BalanceHistoryEntity.java | 34 ++- .../repository/BalanceHistoryRepository.java | 11 +- .../AccountListEntryRepository.java | 11 + .../AuthorizedUserEntryRepository.java | 13 ++ .../repository/BudgetConfigRepository.java | 21 ++ .../bot/repository/ConfigRepository.java | 24 --- .../repository/SecurityConfigRepository.java | 21 ++ .../bot/repository/UserRepository.java | 7 +- .../bot/repository/entity/UserEntity.java | 6 +- .../entity/config/AccountListEntry.java | 24 +++ .../entity/config/AuthorizedUserEntry.java | 24 +++ .../entity/config/BudgetConfigEntity.java | 27 ++- .../entity/config/ConfigEntity.java | 20 -- .../repository/entity/config/ConfigType.java | 6 - .../entity/config/SecurityConfigEntity.java | 26 ++- .../bot/service/ConfigInitializer.java | 28 +-- .../budgetbot/bot/service/ConfigService.java | 48 ++++- .../budgetbot/bot/service/MessageService.java | 14 +- .../bot/service/SecurityService.java | 30 ++- .../service/mapper/BudgetConfigMapper.java | 8 +- .../service/mapper/SecurityConfigMapper.java | 8 +- src/main/resources/application.yaml | 11 +- src/main/resources/db/schema.sql | 39 ++++ src/main/resources/logback.xml | 3 + .../TestBalanceHistoryRepository.java | 5 +- .../bot/service/SecurityServiceTest.java | 21 +- .../resources/application-integration.yaml | 8 +- 31 files changed, 540 insertions(+), 194 deletions(-) create mode 100644 .dockerignore create mode 100644 PERF_PLAN.md create mode 100644 src/main/java/com/home/budgetbot/bot/repository/AccountListEntryRepository.java create mode 100644 src/main/java/com/home/budgetbot/bot/repository/AuthorizedUserEntryRepository.java create mode 100644 src/main/java/com/home/budgetbot/bot/repository/BudgetConfigRepository.java delete mode 100644 src/main/java/com/home/budgetbot/bot/repository/ConfigRepository.java create mode 100644 src/main/java/com/home/budgetbot/bot/repository/SecurityConfigRepository.java create mode 100644 src/main/java/com/home/budgetbot/bot/repository/entity/config/AccountListEntry.java create mode 100644 src/main/java/com/home/budgetbot/bot/repository/entity/config/AuthorizedUserEntry.java delete mode 100644 src/main/java/com/home/budgetbot/bot/repository/entity/config/ConfigEntity.java delete mode 100644 src/main/java/com/home/budgetbot/bot/repository/entity/config/ConfigType.java create mode 100644 src/main/resources/db/schema.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ab5398b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target/ +.git/ +data/ +docs/ +openspec/ +.claude/ +.DS_Store diff --git a/Dockerfile b/Dockerfile index d150dcf..656349f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,15 +2,18 @@ FROM maven:3.9-eclipse-temurin-21 AS build WORKDIR /app COPY pom.xml . -RUN mvn -q -B dependency:go-offline +RUN --mount=type=cache,target=/root/.m2,sharing=locked \ + mvn -q -B dependency:go-offline COPY src ./src -RUN mvn -q -B package -DskipTests +RUN --mount=type=cache,target=/root/.m2,sharing=locked \ + mvn -q -B package -DskipTests # ---- runtime ---- -FROM eclipse-temurin:21-jre +FROM eclipse-temurin:21-jre-alpine WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends curl \ - && rm -rf /var/lib/apt/lists/* -COPY --from=build /app/target/budgetbot-1.0.0.jar /app/app.jar +RUN apk add --no-cache curl +# Warm JVM class-share archive to cut class-loading overhead at startup +RUN java -Xshare:dump 2>/dev/null || true +COPY --from=build /app/target/budgetbot-2.0.0.jar /app/app.jar EXPOSE 7070 ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/PERF_PLAN.md b/PERF_PLAN.md new file mode 100644 index 0000000..270be1a --- /dev/null +++ b/PERF_PLAN.md @@ -0,0 +1,194 @@ +# BudgetBot — Performance Optimization Plan + +## Current baseline (measured 2026-06-12) + +| Metric | Value | +|---|---| +| Docker image (new Alpine) | 262 MB | +| Fat JAR size | 51 MB compressed | +| Cold build time | ~1 min 54 s | +| Warm rebuild (pom.xml changed) | ~36 s (with BuildKit cache mount) | +| Container startup | ~4–5 s (crashes on missing token; Hibernate init = ~3–4 s of that) | + +## JAR size breakdown (uncompressed classes) + +| Package | Size | Root cause | +|---|---|---| +| `org/hibernate` | 46 MB | `micronaut-data-hibernate-jpa` | +| `net/bytebuddy` | 16 MB | Hibernate lazy-load proxy generation | +| `io/micronaut` | 26 MB | framework | +| `org/glassfish` | 20 MB | Glassfish JAXB RI — pulled by Hibernate XML config | +| `io/netty` | 14 MB | Micronaut Netty HTTP server | +| `org/apache` | 9.2 MB | Apache HTTP client via `telegrambots` | +| `com/fasterxml` | 8.6 MB | Jackson | +| `org/telegram` | 4.3 MB | telegrambots | +| `ch/qos` | 3.2 MB | Logback | +| `javassist` | 2.5 MB | Hibernate bytecode manipulation | +| `com/home` | 880 KB | **app code** | + +**Hibernate stack total (hibernate + bytebuddy + javassist + glassfish JAXB): ~85 MB uncompressed** +The app has only 2 entities: `BalanceHistoryEntity`, `BudgetConfigEntity`. + +--- + +## Phase 1 — Micronaut AOT (no code changes) + +**Expected: 10–25% startup improvement** + +Change in `pom.xml`: +```xml +false +``` +→ +```xml +true +``` + +The `micronaut-maven-plugin` already handles AOT processing when this flag is true. +AOT pre-computes bean definitions, eliminates runtime service loader lookups, +and bakes `application.yaml` config into generated code (safe since secrets come from env vars). + +Also fix the Hibernate dialect deprecation warning in `application.yaml` — remove: +```yaml +jpa: + default: + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect +``` +Hibernate 6 auto-detects the dialect from the JDBC URL. + +--- + +## Phase 2 — AppCDS (Dockerfile only, no code changes) + +**Expected: 15–25% startup improvement on top of Phase 1** + +AppCDS bakes a shared class metadata archive into the image. Class loading is the +dominant startup cost for Micronaut — skipping `.class` parsing for already-known +classes saves hundreds of milliseconds. + +Add to `Dockerfile` runtime stage (after COPY of the JAR): +```dockerfile +# Generate default JVM class-share archive (improves class-loading on startup) +RUN java -Xshare:dump 2>/dev/null || true +``` + +This generates the default JDK CDS archive for the Alpine JRE's class library. +Full AppCDS (including app classes) requires a dry-run training pass which needs +a valid bot token — skip for now; the JDK-level CDS still helps. + +--- + +## Phase 3 — Hibernate → Micronaut Data JDBC (code refactor) + +**Expected: ~35 MB JAR reduction (~70% of current 51 MB), startup ~1–2 s instead of ~4–5 s** + +Hibernate is 46 MB + 16 MB ByteBuddy + 2.5 MB Javassist + 20 MB Glassfish JAXB = ~84 MB +uncompressed that goes away entirely. + +### pom.xml changes + +Remove: +```xml + + io.micronaut.data + micronaut-data-hibernate-jpa + +``` +Remove from annotationProcessorPaths: +```xml + + io.micronaut.data + micronaut-data-processor + ${micronaut.data.version} + +``` + +Add: +```xml + + io.micronaut.data + micronaut-data-jdbc + +``` +Add to annotationProcessorPaths: +```xml + + io.micronaut.data + micronaut-data-processor + ${micronaut.data.version} + +``` +(same processor, different runtime dep — keep it) + +Also remove `javax.xml.bind:jaxb-api` — it was added only because Hibernate pulled +`jackson-module-jaxb-annotations` which requires the JAXB API at runtime. +Verify after removal that tests pass. + +### application.yaml changes + +Remove the entire `jpa:` block. +Change `datasources.default` schema init: +```yaml +datasources: + default: + url: jdbc:h2:file:./data/database + driver-class-name: org.h2.Driver + username: admin + password: admin + schema-generate: CREATE_IF_NOT_EXISTS # replaces hbm2ddl.auto: update + dialect: H2 +``` + +### Entity changes (2 files) + +`BalanceHistoryEntity.java` — replace JPA imports: +```java +// Before +import jakarta.persistence.*; +// After +import io.micronaut.data.annotation.*; +import io.micronaut.data.model.naming.NamingStrategies; +``` +`@Entity` → `@MappedEntity` +`@GeneratedValue(strategy = GenerationType.IDENTITY)` → `@GeneratedValue` +`@Column` annotations work as-is (Micronaut Data JDBC supports them). + +`BudgetConfigEntity.java` — same changes. + +### Repository changes (2 files) + +`BalanceHistoryRepository.java`: +```java +// Before +import io.micronaut.data.repository.JpaRepository; +public interface BalanceHistoryRepository extends JpaRepository +// After +import io.micronaut.data.repository.CrudRepository; +public interface BalanceHistoryRepository extends CrudRepository +``` +Any JPQL queries (`@Query` with `from BalanceHistoryEntity`) need rewriting to SQL: +```java +// Before: @Query("SELECT b FROM BalanceHistoryEntity b WHERE b.date >= :from") +// After: @Query("SELECT * FROM balance_history WHERE date >= :from") +``` + +`ConfigRepository.java` — same pattern. + +### What stays the same +- Hikari connection pool config +- H2 database file location (`./data/database`) +- All service/controller/bot code above the repository layer +- All tests (they use `@MicronautTest` which works with JDBC too) + +--- + +## Verification checklist + +- [ ] Phase 1: `mvn package` succeeds with AOT enabled; startup log shows AOT init messages +- [ ] Phase 2: Docker build includes the `java -Xshare:dump` line +- [ ] Phase 3: `mvn test` — all 23 tests pass +- [ ] Phase 3: JAR size drops below 20 MB +- [ ] Phase 3: Container startup under 2 seconds (check logs) +- [ ] Phase 3: `./data/database.mv.db` file created on first run (schema auto-created) diff --git a/pom.xml b/pom.xml index a4270b6..d144258 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.home budgetbot - 1.0.0 + 2.0.0 budgetbot @@ -20,7 +20,7 @@ 21 4.7.6 netty - false + true com.home.budgetbot.Application 1.6.3 @@ -63,10 +63,10 @@ runtime - + io.micronaut.data - micronaut-data-hibernate-jpa + micronaut-data-jdbc io.micronaut.sql @@ -112,17 +112,6 @@ runtime - - - javax.xml.bind - jaxb-api - 2.3.1 - - io.micronaut.test @@ -206,3 +195,4 @@ + diff --git a/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryEntity.java b/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryEntity.java index c6c31a3..07b1f6a 100644 --- a/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryEntity.java +++ b/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryEntity.java @@ -1,46 +1,44 @@ package com.home.budgetbot.bank.repository; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import io.micronaut.data.annotation.MappedProperty; import lombok.Data; import lombok.ToString; import java.time.OffsetDateTime; +import java.util.UUID; -@Entity @Data @ToString -@Table(name = "balance_history") +@MappedEntity("balance_history") public class BalanceHistoryEntity { @Id - @GeneratedValue(strategy = GenerationType.UUID) - @Column(name = "uuid") + @MappedProperty("uuid") private String id; + private String accountId; + private int balance; + private int penny; + private OffsetDateTime time; + public BalanceHistoryEntity() { + this.id = UUID.randomUUID().toString(); } public BalanceHistoryEntity(String accountId, int balance, int penny, OffsetDateTime time) { + this(); this.accountId = accountId; this.balance = balance; - this.time = time; this.penny = penny; + this.time = time; } public BalanceHistoryEntity(String accountId, int balance, OffsetDateTime time) { + this(); this.accountId = accountId; this.balance = balance; - this.time = time; this.penny = 0; + this.time = time; } - - @Column(name = "account_id") - private String accountId; - private int balance; - private int penny; - private OffsetDateTime time; } diff --git a/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryRepository.java b/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryRepository.java index 9b1147c..f6e1fd3 100644 --- a/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryRepository.java +++ b/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryRepository.java @@ -1,20 +1,21 @@ package com.home.budgetbot.bank.repository; import io.micronaut.data.annotation.Query; -import io.micronaut.data.annotation.Repository; +import io.micronaut.data.jdbc.annotation.JdbcRepository; import io.micronaut.data.model.Pageable; +import io.micronaut.data.model.query.builder.sql.Dialect; import io.micronaut.data.repository.GenericRepository; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; -@Repository +@JdbcRepository(dialect = Dialect.H2) public interface BalanceHistoryRepository extends GenericRepository { BalanceHistoryEntity save(BalanceHistoryEntity entity); - @Query("SELECT b FROM BalanceHistoryEntity b WHERE b.accountId = :accountId ORDER BY b.time DESC") + @Query("SELECT * FROM balance_history WHERE account_id = :accountId ORDER BY time DESC") List findByAccountIdOrderByTimeDescPaged(String accountId, Pageable pageable); default BalanceHistoryEntity findTop1ByAccountIdOrderByTimeDesc(String accountId) { @@ -22,13 +23,13 @@ default BalanceHistoryEntity findTop1ByAccountIdOrderByTimeDesc(String accountId .stream().findFirst().orElse(null); } - @Query("SELECT b FROM BalanceHistoryEntity b WHERE b.accountId = :accountId AND b.time < :time ORDER BY b.time DESC") + @Query("SELECT * FROM balance_history WHERE account_id = :accountId AND time < :time ORDER BY time DESC") List findBalanceBeforeTime(String accountId, OffsetDateTime time, Pageable pageable); default Optional findLastBalanceBeforeTime(String accountId, OffsetDateTime time) { return findBalanceBeforeTime(accountId, time, Pageable.from(0, 1)).stream().findFirst(); } - @Query("SELECT b FROM BalanceHistoryEntity b WHERE b.accountId = :accountId AND b.time BETWEEN :from AND :to") + @Query("SELECT * FROM balance_history WHERE account_id = :accountId AND time BETWEEN :from AND :to") List findByAccountIdAndTimeBetween(String accountId, OffsetDateTime from, OffsetDateTime to); } diff --git a/src/main/java/com/home/budgetbot/bot/repository/AccountListEntryRepository.java b/src/main/java/com/home/budgetbot/bot/repository/AccountListEntryRepository.java new file mode 100644 index 0000000..78796da --- /dev/null +++ b/src/main/java/com/home/budgetbot/bot/repository/AccountListEntryRepository.java @@ -0,0 +1,11 @@ +package com.home.budgetbot.bot.repository; + +import com.home.budgetbot.bot.repository.entity.config.AccountListEntry; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +@JdbcRepository(dialect = Dialect.H2) +public interface AccountListEntryRepository extends CrudRepository { + void deleteByBudgetConfigId(Long budgetConfigId); +} diff --git a/src/main/java/com/home/budgetbot/bot/repository/AuthorizedUserEntryRepository.java b/src/main/java/com/home/budgetbot/bot/repository/AuthorizedUserEntryRepository.java new file mode 100644 index 0000000..45c0bcd --- /dev/null +++ b/src/main/java/com/home/budgetbot/bot/repository/AuthorizedUserEntryRepository.java @@ -0,0 +1,13 @@ +package com.home.budgetbot.bot.repository; + +import com.home.budgetbot.bot.repository.entity.config.AuthorizedUserEntry; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +@JdbcRepository(dialect = Dialect.H2) +public interface AuthorizedUserEntryRepository extends CrudRepository { + void deleteBySecurityConfigId(Long securityConfigId); + + boolean existsBySecurityConfigIdAndUserId(Long securityConfigId, Integer userId); +} diff --git a/src/main/java/com/home/budgetbot/bot/repository/BudgetConfigRepository.java b/src/main/java/com/home/budgetbot/bot/repository/BudgetConfigRepository.java new file mode 100644 index 0000000..1581521 --- /dev/null +++ b/src/main/java/com/home/budgetbot/bot/repository/BudgetConfigRepository.java @@ -0,0 +1,21 @@ +package com.home.budgetbot.bot.repository; + +import com.home.budgetbot.bot.repository.entity.config.BudgetConfigEntity; +import io.micronaut.data.annotation.Join; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; +import java.util.Optional; + +@JdbcRepository(dialect = Dialect.H2) +public interface BudgetConfigRepository extends CrudRepository { + @Join(value = "accountList", type = Join.Type.LEFT_FETCH) + @Override + Optional findById(Long id); + + @Join(value = "accountList", type = Join.Type.LEFT_FETCH) + @Override + List findAll(); +} diff --git a/src/main/java/com/home/budgetbot/bot/repository/ConfigRepository.java b/src/main/java/com/home/budgetbot/bot/repository/ConfigRepository.java deleted file mode 100644 index 920aab2..0000000 --- a/src/main/java/com/home/budgetbot/bot/repository/ConfigRepository.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.home.budgetbot.bot.repository; - -import com.home.budgetbot.bot.repository.entity.config.BudgetConfigEntity; -import com.home.budgetbot.bot.repository.entity.config.ConfigEntity; -import com.home.budgetbot.bot.repository.entity.config.ConfigType; -import com.home.budgetbot.bot.repository.entity.config.SecurityConfigEntity; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.data.annotation.Query; -import io.micronaut.data.annotation.Repository; -import io.micronaut.data.repository.GenericRepository; - -@Repository -public interface ConfigRepository extends GenericRepository { - - S update(S entity); - - @Nullable - @Query("SELECT config FROM BudgetConfigEntity config") - BudgetConfigEntity getBudgetConfig(); - - @Nullable - @Query("SELECT config FROM SecurityConfigEntity config") - SecurityConfigEntity getSecurityConfig(); -} diff --git a/src/main/java/com/home/budgetbot/bot/repository/SecurityConfigRepository.java b/src/main/java/com/home/budgetbot/bot/repository/SecurityConfigRepository.java new file mode 100644 index 0000000..25d8bc4 --- /dev/null +++ b/src/main/java/com/home/budgetbot/bot/repository/SecurityConfigRepository.java @@ -0,0 +1,21 @@ +package com.home.budgetbot.bot.repository; + +import com.home.budgetbot.bot.repository.entity.config.SecurityConfigEntity; +import io.micronaut.data.annotation.Join; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; +import java.util.Optional; + +@JdbcRepository(dialect = Dialect.H2) +public interface SecurityConfigRepository extends CrudRepository { + @Join(value = "authorizedUserList", type = Join.Type.LEFT_FETCH) + @Override + Optional findById(Long id); + + @Join(value = "authorizedUserList", type = Join.Type.LEFT_FETCH) + @Override + List findAll(); +} diff --git a/src/main/java/com/home/budgetbot/bot/repository/UserRepository.java b/src/main/java/com/home/budgetbot/bot/repository/UserRepository.java index f057d2b..d8f3fa1 100644 --- a/src/main/java/com/home/budgetbot/bot/repository/UserRepository.java +++ b/src/main/java/com/home/budgetbot/bot/repository/UserRepository.java @@ -2,19 +2,20 @@ import com.home.budgetbot.bot.repository.entity.UserEntity; import io.micronaut.data.annotation.Query; -import io.micronaut.data.annotation.Repository; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; import io.micronaut.data.repository.GenericRepository; import java.util.List; import java.util.Optional; -@Repository +@JdbcRepository(dialect = Dialect.H2) public interface UserRepository extends GenericRepository { UserEntity save(UserEntity entity); List findAll(); - @Query("SELECT u FROM UserEntity u WHERE u.id = :id") + @Query("SELECT * FROM user_entity WHERE id = :id") Optional findById(Integer id); } diff --git a/src/main/java/com/home/budgetbot/bot/repository/entity/UserEntity.java b/src/main/java/com/home/budgetbot/bot/repository/entity/UserEntity.java index 442e9dc..57a469e 100644 --- a/src/main/java/com/home/budgetbot/bot/repository/entity/UserEntity.java +++ b/src/main/java/com/home/budgetbot/bot/repository/entity/UserEntity.java @@ -1,13 +1,13 @@ package com.home.budgetbot.bot.repository.entity; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; import lombok.Data; import lombok.experimental.Accessors; @Data -@Entity @Accessors(chain = true) +@MappedEntity("user_entity") public class UserEntity { @Id private int id; diff --git a/src/main/java/com/home/budgetbot/bot/repository/entity/config/AccountListEntry.java b/src/main/java/com/home/budgetbot/bot/repository/entity/config/AccountListEntry.java new file mode 100644 index 0000000..4322dfa --- /dev/null +++ b/src/main/java/com/home/budgetbot/bot/repository/entity/config/AccountListEntry.java @@ -0,0 +1,24 @@ +package com.home.budgetbot.bot.repository.entity.config; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import lombok.Data; + +@Data +@MappedEntity("budget_account") +public class AccountListEntry { + @Id + @GeneratedValue + private Long id; + + private Long budgetConfigId; + private String account; + + public AccountListEntry() {} + + public AccountListEntry(Long budgetConfigId, String account) { + this.budgetConfigId = budgetConfigId; + this.account = account; + } +} diff --git a/src/main/java/com/home/budgetbot/bot/repository/entity/config/AuthorizedUserEntry.java b/src/main/java/com/home/budgetbot/bot/repository/entity/config/AuthorizedUserEntry.java new file mode 100644 index 0000000..d4ff4a0 --- /dev/null +++ b/src/main/java/com/home/budgetbot/bot/repository/entity/config/AuthorizedUserEntry.java @@ -0,0 +1,24 @@ +package com.home.budgetbot.bot.repository.entity.config; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import lombok.Data; + +@Data +@MappedEntity("security_user") +public class AuthorizedUserEntry { + @Id + @GeneratedValue + private Long id; + + private Long securityConfigId; + private Integer userId; + + public AuthorizedUserEntry() {} + + public AuthorizedUserEntry(Long securityConfigId, Integer userId) { + this.securityConfigId = securityConfigId; + this.userId = userId; + } +} diff --git a/src/main/java/com/home/budgetbot/bot/repository/entity/config/BudgetConfigEntity.java b/src/main/java/com/home/budgetbot/bot/repository/entity/config/BudgetConfigEntity.java index a938673..c4b750b 100644 --- a/src/main/java/com/home/budgetbot/bot/repository/entity/config/BudgetConfigEntity.java +++ b/src/main/java/com/home/budgetbot/bot/repository/entity/config/BudgetConfigEntity.java @@ -1,27 +1,26 @@ package com.home.budgetbot.bot.repository.entity.config; -import jakarta.persistence.CollectionTable; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import io.micronaut.data.annotation.Relation; import lombok.Data; import lombok.experimental.Accessors; import java.util.ArrayList; -import java.util.Collection; +import java.util.List; @Data -@Entity @Accessors(chain = true) -public class BudgetConfigEntity extends ConfigEntity { +@MappedEntity("budget_config") +public class BudgetConfigEntity { + @Id + @GeneratedValue + private Long id; + private int salaryDay; private int budgetLimit; - @ElementCollection(fetch = FetchType.EAGER) - @CollectionTable(name = "account_list") - private Collection accountList = new ArrayList<>(); - - public BudgetConfigEntity() { - this.setType(ConfigType.BUDGET); - } + @Relation(value = Relation.Kind.ONE_TO_MANY, mappedBy = "budgetConfigId") + private List accountList = new ArrayList<>(); } diff --git a/src/main/java/com/home/budgetbot/bot/repository/entity/config/ConfigEntity.java b/src/main/java/com/home/budgetbot/bot/repository/entity/config/ConfigEntity.java deleted file mode 100644 index 7461276..0000000 --- a/src/main/java/com/home/budgetbot/bot/repository/entity/config/ConfigEntity.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.home.budgetbot.bot.repository.entity.config; - -import jakarta.persistence.Enumerated; -import jakarta.persistence.EnumType; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Inheritance; -import jakarta.persistence.InheritanceType; -import lombok.Data; -import lombok.experimental.Accessors; - -@Data -@Entity -@Accessors(chain = true) -@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) -public class ConfigEntity { - @Id - @Enumerated(EnumType.STRING) - protected ConfigType type; -} diff --git a/src/main/java/com/home/budgetbot/bot/repository/entity/config/ConfigType.java b/src/main/java/com/home/budgetbot/bot/repository/entity/config/ConfigType.java deleted file mode 100644 index 81b8f1f..0000000 --- a/src/main/java/com/home/budgetbot/bot/repository/entity/config/ConfigType.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.home.budgetbot.bot.repository.entity.config; - -public enum ConfigType { - BUDGET, - SECURITY -} diff --git a/src/main/java/com/home/budgetbot/bot/repository/entity/config/SecurityConfigEntity.java b/src/main/java/com/home/budgetbot/bot/repository/entity/config/SecurityConfigEntity.java index d90bb34..45dbc21 100644 --- a/src/main/java/com/home/budgetbot/bot/repository/entity/config/SecurityConfigEntity.java +++ b/src/main/java/com/home/budgetbot/bot/repository/entity/config/SecurityConfigEntity.java @@ -1,25 +1,23 @@ package com.home.budgetbot.bot.repository.entity.config; -import jakarta.persistence.CollectionTable; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import io.micronaut.data.annotation.Relation; import lombok.Data; import lombok.experimental.Accessors; import java.util.ArrayList; -import java.util.Collection; +import java.util.List; @Data -@Entity @Accessors(chain = true) -public class SecurityConfigEntity extends ConfigEntity { +@MappedEntity("security_config") +public class SecurityConfigEntity { + @Id + @GeneratedValue + private Long id; - @ElementCollection(fetch = FetchType.EAGER) - @CollectionTable(name = "authorized_user_list") - private Collection authorizedUserList = new ArrayList<>(); - - public SecurityConfigEntity() { - this.setType(ConfigType.SECURITY); - } + @Relation(value = Relation.Kind.ONE_TO_MANY, mappedBy = "securityConfigId") + private List authorizedUserList = new ArrayList<>(); } diff --git a/src/main/java/com/home/budgetbot/bot/service/ConfigInitializer.java b/src/main/java/com/home/budgetbot/bot/service/ConfigInitializer.java index 7caca73..c66829a 100644 --- a/src/main/java/com/home/budgetbot/bot/service/ConfigInitializer.java +++ b/src/main/java/com/home/budgetbot/bot/service/ConfigInitializer.java @@ -1,6 +1,9 @@ package com.home.budgetbot.bot.service; -import com.home.budgetbot.bot.repository.ConfigRepository; +import com.home.budgetbot.bot.repository.AccountListEntryRepository; +import com.home.budgetbot.bot.repository.BudgetConfigRepository; +import com.home.budgetbot.bot.repository.SecurityConfigRepository; +import com.home.budgetbot.bot.repository.entity.config.AccountListEntry; import com.home.budgetbot.bot.repository.entity.config.BudgetConfigEntity; import com.home.budgetbot.bot.repository.entity.config.SecurityConfigEntity; import io.micronaut.context.event.StartupEvent; @@ -9,8 +12,6 @@ import jakarta.inject.Singleton; import lombok.extern.slf4j.Slf4j; -import java.util.Arrays; - /** * Seeds default configuration on application startup. Kept separate from * {@link ConfigService} so the latter stays plainly mockable in tests, and so the @@ -21,25 +22,28 @@ public class ConfigInitializer { @Inject - ConfigRepository configRepository; + BudgetConfigRepository budgetConfigRepository; + + @Inject + AccountListEntryRepository accountListEntryRepository; + + @Inject + SecurityConfigRepository securityConfigRepository; @EventListener public void onStartup(StartupEvent event) { - if (configRepository.getBudgetConfig() == null) { + if (budgetConfigRepository.count() == 0) { log.info("Budget config not found. Init default values."); - BudgetConfigEntity config = new BudgetConfigEntity() .setSalaryDay(5) - .setAccountList(Arrays.asList("remove_me_example_id")) .setBudgetLimit(900); - - configRepository.update(config); + BudgetConfigEntity saved = budgetConfigRepository.save(config); + accountListEntryRepository.save(new AccountListEntry(saved.getId(), "remove_me_example_id")); } - if (configRepository.getSecurityConfig() == null) { + if (securityConfigRepository.count() == 0) { log.info("Security config not found. Init default values."); - - configRepository.update(new SecurityConfigEntity()); + securityConfigRepository.save(new SecurityConfigEntity()); } } } diff --git a/src/main/java/com/home/budgetbot/bot/service/ConfigService.java b/src/main/java/com/home/budgetbot/bot/service/ConfigService.java index 63b742e..36dddf6 100644 --- a/src/main/java/com/home/budgetbot/bot/service/ConfigService.java +++ b/src/main/java/com/home/budgetbot/bot/service/ConfigService.java @@ -1,6 +1,11 @@ package com.home.budgetbot.bot.service; -import com.home.budgetbot.bot.repository.ConfigRepository; +import com.home.budgetbot.bot.repository.AccountListEntryRepository; +import com.home.budgetbot.bot.repository.AuthorizedUserEntryRepository; +import com.home.budgetbot.bot.repository.BudgetConfigRepository; +import com.home.budgetbot.bot.repository.SecurityConfigRepository; +import com.home.budgetbot.bot.repository.entity.config.AccountListEntry; +import com.home.budgetbot.bot.repository.entity.config.AuthorizedUserEntry; import com.home.budgetbot.bot.repository.entity.config.BudgetConfigEntity; import com.home.budgetbot.bot.repository.entity.config.SecurityConfigEntity; import com.home.budgetbot.bot.service.mapper.BudgetConfigMapper; @@ -10,6 +15,7 @@ import com.home.budgetbot.bot.service.model.SecurityConfigModel; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -17,7 +23,16 @@ public class ConfigService { @Inject - ConfigRepository configRepository; + BudgetConfigRepository budgetConfigRepository; + + @Inject + AccountListEntryRepository accountListEntryRepository; + + @Inject + SecurityConfigRepository securityConfigRepository; + + @Inject + AuthorizedUserEntryRepository authorizedUserEntryRepository; @Inject BudgetConfigMapper budgetConfigMapper; @@ -26,10 +41,10 @@ public class ConfigService { SecurityConfigMapper securityConfigMapper; public ConfigModel getConfig() { - BudgetConfigEntity budgetConfig = configRepository.getBudgetConfig(); + BudgetConfigEntity budgetConfig = getFirst(budgetConfigRepository.findAll()); BudgetConfigModel budgetConfigModel = budgetConfigMapper.map(budgetConfig); - SecurityConfigEntity securityConfig = configRepository.getSecurityConfig(); + SecurityConfigEntity securityConfig = getFirst(securityConfigRepository.findAll()); SecurityConfigModel securityConfigModel = securityConfigMapper.map(securityConfig); return new ConfigModel() @@ -37,11 +52,28 @@ public ConfigModel getConfig() { .setSecurity(securityConfigModel); } + @Transactional public void setConfig(ConfigModel config) { - BudgetConfigEntity budgetEntity = budgetConfigMapper.map(config.getBudget()); - configRepository.update(budgetEntity); + BudgetConfigEntity budget = getFirst(budgetConfigRepository.findAll()); + budget.setSalaryDay(config.getBudget().getSalaryDay()); + budget.setBudgetLimit(config.getBudget().getBudgetLimit()); + budgetConfigRepository.update(budget); + + accountListEntryRepository.deleteByBudgetConfigId(budget.getId()); + for (String account : config.getBudget().getAccountList()) { + accountListEntryRepository.save(new AccountListEntry(budget.getId(), account)); + } + + SecurityConfigEntity security = getFirst(securityConfigRepository.findAll()); + authorizedUserEntryRepository.deleteBySecurityConfigId(security.getId()); + for (Integer userId : config.getSecurity().getAuthorizedUserList()) { + authorizedUserEntryRepository.save(new AuthorizedUserEntry(security.getId(), userId)); + } + } - SecurityConfigEntity securityConfigEntity = securityConfigMapper.map(config.getSecurity()); - configRepository.update(securityConfigEntity); + private T getFirst(Iterable iterable) { + java.util.Iterator it = iterable.iterator(); + if (!it.hasNext()) throw new IllegalStateException("Config not initialized"); + return it.next(); } } diff --git a/src/main/java/com/home/budgetbot/bot/service/MessageService.java b/src/main/java/com/home/budgetbot/bot/service/MessageService.java index 7210e82..abe5c7f 100644 --- a/src/main/java/com/home/budgetbot/bot/service/MessageService.java +++ b/src/main/java/com/home/budgetbot/bot/service/MessageService.java @@ -1,8 +1,10 @@ package com.home.budgetbot.bot.service; -import com.home.budgetbot.bot.repository.ConfigRepository; +import com.home.budgetbot.bot.repository.SecurityConfigRepository; import com.home.budgetbot.bot.repository.UserRepository; import com.home.budgetbot.bot.repository.entity.UserEntity; +import com.home.budgetbot.bot.repository.entity.config.AuthorizedUserEntry; +import com.home.budgetbot.bot.repository.entity.config.SecurityConfigEntity; import com.home.budgetbot.bot.service.model.MessageModel; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -12,7 +14,6 @@ import org.telegram.telegrambots.meta.api.methods.send.SendMessage; import org.telegram.telegrambots.meta.exceptions.TelegramApiException; -import java.util.Collection; import java.util.List; import java.util.stream.Collectors; @@ -27,19 +28,20 @@ public class MessageService { UserRepository userRepository; @Inject - ConfigRepository configRepository; + SecurityConfigRepository securityConfigRepository; public void notifyAll(MessageModel messageModel) { - Collection authorizedUserList = configRepository.getSecurityConfig().getAuthorizedUserList(); + List configs = securityConfigRepository.findAll(); + if (configs.isEmpty()) throw new IllegalStateException("Security config not initialized"); + List authorizedUsers = configs.get(0).getAuthorizedUserList(); List chatList = userRepository.findAll().stream() .map(UserEntity::getChatId) - .filter(target -> authorizedUserList.stream().anyMatch(id -> id.longValue() == target)) + .filter(chatId -> authorizedUsers.stream().anyMatch(e -> e.getUserId() != null && e.getUserId().longValue() == chatId)) .map(String::valueOf) .collect(Collectors.toList()); messageModel.setChatList(chatList); - sendMessage(messageModel); } diff --git a/src/main/java/com/home/budgetbot/bot/service/SecurityService.java b/src/main/java/com/home/budgetbot/bot/service/SecurityService.java index ff91df5..4c1f14c 100644 --- a/src/main/java/com/home/budgetbot/bot/service/SecurityService.java +++ b/src/main/java/com/home/budgetbot/bot/service/SecurityService.java @@ -1,13 +1,14 @@ package com.home.budgetbot.bot.service; -import com.home.budgetbot.bot.repository.ConfigRepository; +import com.home.budgetbot.bot.repository.AuthorizedUserEntryRepository; +import com.home.budgetbot.bot.repository.SecurityConfigRepository; +import com.home.budgetbot.bot.repository.entity.config.AuthorizedUserEntry; import com.home.budgetbot.bot.repository.entity.config.SecurityConfigEntity; import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.extern.slf4j.Slf4j; import org.telegram.telegrambots.meta.api.objects.User; -import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -16,10 +17,13 @@ public class SecurityService { @Inject - ConfigRepository configRepository; + SecurityConfigRepository securityConfigRepository; + + @Inject + AuthorizedUserEntryRepository authorizedUserEntryRepository; public synchronized boolean isAuthorizedUser(User user) { - SecurityConfigEntity securityConfig = configRepository.getSecurityConfig(); + SecurityConfigEntity securityConfig = getConfig(); if (isAdminNotExist(securityConfig)) { log.warn("Admin not exist, save {} as admin", user.getUserName()); @@ -27,20 +31,24 @@ public synchronized boolean isAuthorizedUser(User user) { return true; } - List userIdList = new ArrayList<>(securityConfig.getAuthorizedUserList()); - - return userIdList.contains(user.getId().intValue()); + return securityConfig.getAuthorizedUserList().stream() + .anyMatch(e -> e.getUserId() != null && e.getUserId() == user.getId().intValue()); } public synchronized void addAuthorizedUser(Integer userId) { - SecurityConfigEntity securityConfig = configRepository.getSecurityConfig(); + SecurityConfigEntity securityConfig = getConfig(); - if (!securityConfig.getAuthorizedUserList().contains(userId)) { - securityConfig.getAuthorizedUserList().add(userId); - configRepository.update(securityConfig); + if (!authorizedUserEntryRepository.existsBySecurityConfigIdAndUserId(securityConfig.getId(), userId)) { + authorizedUserEntryRepository.save(new AuthorizedUserEntry(securityConfig.getId(), userId)); } } + private SecurityConfigEntity getConfig() { + List all = securityConfigRepository.findAll(); + if (all.isEmpty()) throw new IllegalStateException("Security config not initialized"); + return all.get(0); + } + private boolean isAdminNotExist(SecurityConfigEntity securityConfig) { return Optional.ofNullable(securityConfig) .map(SecurityConfigEntity::getAuthorizedUserList) diff --git a/src/main/java/com/home/budgetbot/bot/service/mapper/BudgetConfigMapper.java b/src/main/java/com/home/budgetbot/bot/service/mapper/BudgetConfigMapper.java index 9013913..6802276 100644 --- a/src/main/java/com/home/budgetbot/bot/service/mapper/BudgetConfigMapper.java +++ b/src/main/java/com/home/budgetbot/bot/service/mapper/BudgetConfigMapper.java @@ -1,12 +1,18 @@ package com.home.budgetbot.bot.service.mapper; +import com.home.budgetbot.bot.repository.entity.config.AccountListEntry; import com.home.budgetbot.bot.repository.entity.config.BudgetConfigEntity; import com.home.budgetbot.bot.service.model.BudgetConfigModel; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; @Mapper(componentModel = "jsr330") public interface BudgetConfigMapper { + + @Mapping(target = "accountList", source = "accountList") BudgetConfigModel map(BudgetConfigEntity entity); - BudgetConfigEntity map(BudgetConfigModel model); + default String toAccount(AccountListEntry entry) { + return entry == null ? null : entry.getAccount(); + } } diff --git a/src/main/java/com/home/budgetbot/bot/service/mapper/SecurityConfigMapper.java b/src/main/java/com/home/budgetbot/bot/service/mapper/SecurityConfigMapper.java index b2debdb..dcbb866 100644 --- a/src/main/java/com/home/budgetbot/bot/service/mapper/SecurityConfigMapper.java +++ b/src/main/java/com/home/budgetbot/bot/service/mapper/SecurityConfigMapper.java @@ -1,12 +1,18 @@ package com.home.budgetbot.bot.service.mapper; +import com.home.budgetbot.bot.repository.entity.config.AuthorizedUserEntry; import com.home.budgetbot.bot.repository.entity.config.SecurityConfigEntity; import com.home.budgetbot.bot.service.model.SecurityConfigModel; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; @Mapper(componentModel = "jsr330") public interface SecurityConfigMapper { + + @Mapping(target = "authorizedUserList", source = "authorizedUserList") SecurityConfigModel map(SecurityConfigEntity entity); - SecurityConfigEntity map(SecurityConfigModel model); + default Integer toUserId(AuthorizedUserEntry entry) { + return entry == null ? null : entry.getUserId(); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e8da14c..26c6d2f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -10,20 +10,13 @@ micronaut: datasources: default: - url: jdbc:h2:file:./data/database + url: "jdbc:h2:file:./data/database;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT FROM 'classpath:db/schema.sql'" driver-class-name: org.h2.Driver username: admin password: admin + schema-generate: NONE dialect: H2 -jpa: - default: - properties: - hibernate: - hbm2ddl: - auto: update - dialect: org.hibernate.dialect.H2Dialect - monobank: base-url: https://api.monobank.ua webhook-public-url: ${MONOBANK_WEBHOOK_PUBLIC_URL:} diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql new file mode 100644 index 0000000..4b29164 --- /dev/null +++ b/src/main/resources/db/schema.sql @@ -0,0 +1,39 @@ +-- Preserved from original Hibernate schema (data must survive migrations) +CREATE TABLE IF NOT EXISTS balance_history ( + uuid VARCHAR(36) PRIMARY KEY, + account_id VARCHAR(255), + balance INTEGER, + penny INTEGER, + time TIMESTAMP WITH TIME ZONE +); + +CREATE TABLE IF NOT EXISTS user_entity ( + id INTEGER PRIMARY KEY, + username VARCHAR(255), + first_name VARCHAR(255), + last_name VARCHAR(255), + chat_id BIGINT +); + +-- New tables replacing Hibernate TABLE_PER_CLASS inheritance + @ElementCollection +CREATE TABLE IF NOT EXISTS budget_config ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + salary_day INTEGER NOT NULL, + budget_limit INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS budget_account ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + budget_config_id BIGINT NOT NULL, + account VARCHAR(255) NOT NULL +); + +CREATE TABLE IF NOT EXISTS security_config ( + id BIGINT AUTO_INCREMENT PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS security_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + security_config_id BIGINT NOT NULL, + user_id INTEGER NOT NULL +); diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 04a72cb..b7a4898 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -6,6 +6,9 @@ + + + diff --git a/src/test/java/com/home/budgetbot/bank/repository/TestBalanceHistoryRepository.java b/src/test/java/com/home/budgetbot/bank/repository/TestBalanceHistoryRepository.java index 93e1c71..b50c4e2 100644 --- a/src/test/java/com/home/budgetbot/bank/repository/TestBalanceHistoryRepository.java +++ b/src/test/java/com/home/budgetbot/bank/repository/TestBalanceHistoryRepository.java @@ -1,9 +1,10 @@ package com.home.budgetbot.bank.repository; import io.micronaut.context.annotation.Replaces; -import io.micronaut.data.annotation.Repository; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; -@Repository +@JdbcRepository(dialect = Dialect.H2) @Replaces(BalanceHistoryRepository.class) public interface TestBalanceHistoryRepository extends BalanceHistoryRepository { void deleteAll(); diff --git a/src/test/java/com/home/budgetbot/bot/service/SecurityServiceTest.java b/src/test/java/com/home/budgetbot/bot/service/SecurityServiceTest.java index 064d7f3..3cf2f5e 100644 --- a/src/test/java/com/home/budgetbot/bot/service/SecurityServiceTest.java +++ b/src/test/java/com/home/budgetbot/bot/service/SecurityServiceTest.java @@ -1,6 +1,7 @@ package com.home.budgetbot.bot.service; -import com.home.budgetbot.bot.repository.ConfigRepository; +import com.home.budgetbot.bot.repository.AuthorizedUserEntryRepository; +import com.home.budgetbot.bot.repository.SecurityConfigRepository; import com.home.budgetbot.bot.repository.entity.config.SecurityConfigEntity; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import jakarta.inject.Inject; @@ -18,13 +19,15 @@ class SecurityServiceTest { SecurityService securityService; @Inject - ConfigRepository configRepository; + SecurityConfigRepository securityConfigRepository; + + @Inject + AuthorizedUserEntryRepository authorizedUserEntryRepository; @BeforeEach void clearAuthorizedUsers() { - SecurityConfigEntity config = configRepository.getSecurityConfig(); - config.getAuthorizedUserList().clear(); - configRepository.update(config); + SecurityConfigEntity config = securityConfigRepository.findAll().iterator().next(); + authorizedUserEntryRepository.deleteBySecurityConfigId(config.getId()); } @Test @@ -34,8 +37,8 @@ void firstUserBecomesAdmin() { boolean result = securityService.isAuthorizedUser(user); assertTrue(result); - SecurityConfigEntity config = configRepository.getSecurityConfig(); - assertTrue(config.getAuthorizedUserList().contains(42)); + SecurityConfigEntity config = securityConfigRepository.findAll().iterator().next(); + assertTrue(config.getAuthorizedUserList().stream().anyMatch(e -> e.getUserId() == 42)); } @Test @@ -61,8 +64,8 @@ void addAuthorizedUserIsIdempotent() { securityService.addAuthorizedUser(7); securityService.addAuthorizedUser(7); - SecurityConfigEntity config = configRepository.getSecurityConfig(); - long count = config.getAuthorizedUserList().stream().filter(id -> id == 7).count(); + SecurityConfigEntity config = securityConfigRepository.findAll().iterator().next(); + long count = config.getAuthorizedUserList().stream().filter(e -> e.getUserId() == 7).count(); assertTrue(count <= 1, "Duplicate entries for userId 7: " + count); } diff --git a/src/test/resources/application-integration.yaml b/src/test/resources/application-integration.yaml index c9473cc..bae7091 100644 --- a/src/test/resources/application-integration.yaml +++ b/src/test/resources/application-integration.yaml @@ -8,15 +8,9 @@ datasources: username: admin password: admin driver-class-name: org.h2.Driver + schema-generate: CREATE dialect: H2 -jpa: - default: - properties: - hibernate: - hbm2ddl: - auto: update - monobank: base-url: "http://localhost:8080" webhook-secret: "test-secret" From ad8de885342ab92e9809ff1c6ba06772257329f8 Mon Sep 17 00:00:00 2001 From: Yurii Kucherenko Date: Sat, 13 Jun 2026 11:44:33 +0300 Subject: [PATCH 2/4] fix: set micronaut.aot.packageName required for AOT analysis --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index d144258..469ce77 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,7 @@ 4.7.6 netty true + com.home.budgetbot com.home.budgetbot.Application 1.6.3 From 9da61cdbc208b2ef15d63a2bf11129e23befc829 Mon Sep 17 00:00:00 2001 From: Yurii Kucherenko Date: Sun, 14 Jun 2026 18:49:28 +0300 Subject: [PATCH 3/4] refactor: native Micronaut Data JDBC, slimmer JAR, unified schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pom: exclude telegrambots' unused Jersey/Grizzly/HK2/Javassist webhook-server stack (long-polling only) — fat JAR 28MB -> 23MB, ~19MB dead weight gone - BalanceHistoryRepository + UserRepository: CrudRepository with compile-time derived queries; drop hand-written @Query + Pageable plumbing - MonobankService: push day-initial-balance sort down into SQL (Optional finders) - schema.sql is the single source of truth; tests now RUNSCRIPT the same schema (schema-generate: NONE) instead of generating lossy DDL from entities - add ASC indexes for balance_history(account_id, time) and the config FKs (NOTE: ASC not DESC — a DESC index on TIMESTAMP WITH TIME ZONE hits an H2 range-scan bug that returns wrong rows) - ConfigInitializer: @Transactional seeding; delete redundant TestBalanceHistoryRepository All 23 tests green. --- pom.xml | 35 ++++++++++++++++++- .../repository/BalanceHistoryRepository.java | 29 ++++++--------- .../bank/service/MonobankService.java | 22 ++++-------- .../bot/repository/UserRepository.java | 19 ++++------ .../bot/service/ConfigInitializer.java | 2 ++ src/main/resources/db/schema.sql | 16 +++++++++ .../bank/event/BalanceSchedulerTest.java | 4 +-- .../BalanceHistoryRepositoryTest.java | 6 ++-- .../TestBalanceHistoryRepository.java | 11 ------ .../bank/service/MonobankServiceTest.java | 4 +-- .../bank/webhook/WebhookControllerTest.java | 4 +-- .../bot/service/BudgetServiceTest.java | 4 +-- .../resources/application-integration.yaml | 6 ++-- 13 files changed, 90 insertions(+), 72 deletions(-) delete mode 100644 src/test/java/com/home/budgetbot/bank/repository/TestBalanceHistoryRepository.java diff --git a/pom.xml b/pom.xml index 469ce77..022c6d5 100644 --- a/pom.xml +++ b/pom.xml @@ -85,11 +85,44 @@ micronaut-management - + org.telegram telegrambots ${telegrambots.version} + + + org.glassfish.jersey.inject + jersey-hk2 + + + org.glassfish.jersey.media + jersey-media-json-jackson + + + org.glassfish.jersey.containers + jersey-container-grizzly2-http + + + org.glassfish.jersey.core + jersey-server + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-json-provider + + + com.fasterxml.jackson.module + jackson-module-jaxb-annotations + + diff --git a/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryRepository.java b/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryRepository.java index f6e1fd3..17a9040 100644 --- a/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryRepository.java +++ b/src/main/java/com/home/budgetbot/bank/repository/BalanceHistoryRepository.java @@ -1,35 +1,26 @@ package com.home.budgetbot.bank.repository; -import io.micronaut.data.annotation.Query; import io.micronaut.data.jdbc.annotation.JdbcRepository; -import io.micronaut.data.model.Pageable; import io.micronaut.data.model.query.builder.sql.Dialect; -import io.micronaut.data.repository.GenericRepository; +import io.micronaut.data.repository.CrudRepository; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; +/** + * All finders are Micronaut Data derived queries: the SQL is generated and + * validated at compile time from the method name — no hand-written @Query, no + * Pageable plumbing. CrudRepository supplies save/deleteAll/count/findById. + */ @JdbcRepository(dialect = Dialect.H2) -public interface BalanceHistoryRepository extends GenericRepository { +public interface BalanceHistoryRepository extends CrudRepository { - BalanceHistoryEntity save(BalanceHistoryEntity entity); + Optional findFirstByAccountIdOrderByTimeDesc(String accountId); - @Query("SELECT * FROM balance_history WHERE account_id = :accountId ORDER BY time DESC") - List findByAccountIdOrderByTimeDescPaged(String accountId, Pageable pageable); + Optional findFirstByAccountIdAndTimeLessThanOrderByTimeDesc(String accountId, OffsetDateTime time); - default BalanceHistoryEntity findTop1ByAccountIdOrderByTimeDesc(String accountId) { - return findByAccountIdOrderByTimeDescPaged(accountId, Pageable.from(0, 1)) - .stream().findFirst().orElse(null); - } + Optional findFirstByAccountIdAndTimeBetweenOrderByTimeAsc(String accountId, OffsetDateTime from, OffsetDateTime to); - @Query("SELECT * FROM balance_history WHERE account_id = :accountId AND time < :time ORDER BY time DESC") - List findBalanceBeforeTime(String accountId, OffsetDateTime time, Pageable pageable); - - default Optional findLastBalanceBeforeTime(String accountId, OffsetDateTime time) { - return findBalanceBeforeTime(accountId, time, Pageable.from(0, 1)).stream().findFirst(); - } - - @Query("SELECT * FROM balance_history WHERE account_id = :accountId AND time BETWEEN :from AND :to") List findByAccountIdAndTimeBetween(String accountId, OffsetDateTime from, OffsetDateTime to); } diff --git a/src/main/java/com/home/budgetbot/bank/service/MonobankService.java b/src/main/java/com/home/budgetbot/bank/service/MonobankService.java index ef03abd..d835f6a 100644 --- a/src/main/java/com/home/budgetbot/bank/service/MonobankService.java +++ b/src/main/java/com/home/budgetbot/bank/service/MonobankService.java @@ -8,7 +8,6 @@ import jakarta.inject.Singleton; import java.time.OffsetDateTime; -import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -27,13 +26,8 @@ public class MonobankService implements BankService { @Override public Optional findLastBalance(String accountId) { - BalanceHistoryEntity history = repository.findTop1ByAccountIdOrderByTimeDesc(accountId); - - if (history == null) { - return Optional.empty(); - } - - return Optional.of(history.getBalance()); + return repository.findFirstByAccountIdOrderByTimeDesc(accountId) + .map(BalanceHistoryEntity::getBalance); } @Override @@ -57,17 +51,15 @@ private OffsetDateTime getDayEnd(OffsetDateTime dateTime) { @Override public Optional findInitialBalanceByDay(String accountId, OffsetDateTime dateTime) { OffsetDateTime dayStart = getDayStart(dateTime); - Optional balance = repository.findLastBalanceBeforeTime(accountId, dayStart).map(BalanceHistoryEntity::getBalance); + Optional balance = repository.findFirstByAccountIdAndTimeLessThanOrderByTimeDesc(accountId, dayStart) + .map(BalanceHistoryEntity::getBalance); if (balance.isPresent()) { return balance; } - return repository.findByAccountIdAndTimeBetween(accountId, getDayStart(dateTime), getDayEnd(dateTime)) - .stream() - .sorted(Comparator.comparing(BalanceHistoryEntity::getTime)) - .map(BalanceHistoryEntity::getBalance) - .findFirst(); + return repository.findFirstByAccountIdAndTimeBetweenOrderByTimeAsc(accountId, getDayStart(dateTime), getDayEnd(dateTime)) + .map(BalanceHistoryEntity::getBalance); } @Override @@ -79,7 +71,7 @@ public Optional findBalanceDifferenceByDay(String accountId, OffsetDate return Optional.empty(); } - Integer firstDayBalance = repository.findLastBalanceBeforeTime(accountId, getDayStart(dateTime)) + Integer firstDayBalance = repository.findFirstByAccountIdAndTimeLessThanOrderByTimeDesc(accountId, getDayStart(dateTime)) .map(BalanceHistoryEntity::getBalance) .orElse(balanceHistory.get(0)); diff --git a/src/main/java/com/home/budgetbot/bot/repository/UserRepository.java b/src/main/java/com/home/budgetbot/bot/repository/UserRepository.java index d8f3fa1..28eb86b 100644 --- a/src/main/java/com/home/budgetbot/bot/repository/UserRepository.java +++ b/src/main/java/com/home/budgetbot/bot/repository/UserRepository.java @@ -1,21 +1,14 @@ package com.home.budgetbot.bot.repository; import com.home.budgetbot.bot.repository.entity.UserEntity; -import io.micronaut.data.annotation.Query; import io.micronaut.data.jdbc.annotation.JdbcRepository; import io.micronaut.data.model.query.builder.sql.Dialect; -import io.micronaut.data.repository.GenericRepository; - -import java.util.List; -import java.util.Optional; +import io.micronaut.data.repository.CrudRepository; +/** + * CrudRepository supplies save / findById / findAll / count out of the box — + * no @Query needed for these standard operations. + */ @JdbcRepository(dialect = Dialect.H2) -public interface UserRepository extends GenericRepository { - - UserEntity save(UserEntity entity); - - List findAll(); - - @Query("SELECT * FROM user_entity WHERE id = :id") - Optional findById(Integer id); +public interface UserRepository extends CrudRepository { } diff --git a/src/main/java/com/home/budgetbot/bot/service/ConfigInitializer.java b/src/main/java/com/home/budgetbot/bot/service/ConfigInitializer.java index c66829a..62227c3 100644 --- a/src/main/java/com/home/budgetbot/bot/service/ConfigInitializer.java +++ b/src/main/java/com/home/budgetbot/bot/service/ConfigInitializer.java @@ -10,6 +10,7 @@ import io.micronaut.runtime.event.annotation.EventListener; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; import lombok.extern.slf4j.Slf4j; /** @@ -31,6 +32,7 @@ public class ConfigInitializer { SecurityConfigRepository securityConfigRepository; @EventListener + @Transactional public void onStartup(StartupEvent event) { if (budgetConfigRepository.count() == 0) { log.info("Budget config not found. Init default values."); diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 4b29164..285e27d 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -37,3 +37,19 @@ CREATE TABLE IF NOT EXISTS security_user ( security_config_id BIGINT NOT NULL, user_id INTEGER NOT NULL ); + + +-- Indexes for the app's access patterns. +-- balance_history is always filtered by account_id and ordered by time +-- (findTop1..., findLastBalanceBeforeTime, findByAccountIdAndTimeBetween). +-- NOTE: keep this index ASCENDING. A DESC index on a TIMESTAMP WITH TIME ZONE +-- column triggers an H2 range-scan bug that returns wrong rows for +-- "WHERE time < ? ORDER BY time DESC" queries. H2 scans an ASC index backwards +-- for DESC ordering anyway, so there is no performance loss. +CREATE INDEX IF NOT EXISTS idx_balance_history_account_time + ON balance_history (account_id, time); +-- Foreign-key columns used by the config delete/join paths. +CREATE INDEX IF NOT EXISTS idx_budget_account_config + ON budget_account (budget_config_id); +CREATE INDEX IF NOT EXISTS idx_security_user_config + ON security_user (security_config_id); diff --git a/src/test/java/com/home/budgetbot/bank/event/BalanceSchedulerTest.java b/src/test/java/com/home/budgetbot/bank/event/BalanceSchedulerTest.java index a8f148c..4e88951 100644 --- a/src/test/java/com/home/budgetbot/bank/event/BalanceSchedulerTest.java +++ b/src/test/java/com/home/budgetbot/bank/event/BalanceSchedulerTest.java @@ -8,7 +8,7 @@ import com.home.budgetbot.bank.model.BalanceChangedEvent; import com.home.budgetbot.bank.model.BalanceChangedWebhookInput; import com.home.budgetbot.bank.model.BalanceChangedWebhookInput.AccountData; -import com.home.budgetbot.bank.repository.TestBalanceHistoryRepository; +import com.home.budgetbot.bank.repository.BalanceHistoryRepository; import com.home.budgetbot.bank.service.BalanceService; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import jakarta.inject.Inject; @@ -27,7 +27,7 @@ class BalanceSchedulerTest { BalanceService balanceService; @Inject - TestBalanceHistoryRepository historyRepository; + BalanceHistoryRepository historyRepository; @Inject BalanceChangeEventListener eventListener; diff --git a/src/test/java/com/home/budgetbot/bank/repository/BalanceHistoryRepositoryTest.java b/src/test/java/com/home/budgetbot/bank/repository/BalanceHistoryRepositoryTest.java index 84cbd93..c01894a 100644 --- a/src/test/java/com/home/budgetbot/bank/repository/BalanceHistoryRepositoryTest.java +++ b/src/test/java/com/home/budgetbot/bank/repository/BalanceHistoryRepositoryTest.java @@ -15,7 +15,7 @@ class BalanceHistoryRepositoryTest { public static final String ACCOUNT_ID = "TEST"; @Inject - TestBalanceHistoryRepository historyRepository; + BalanceHistoryRepository historyRepository; @BeforeEach void setUp() { @@ -44,7 +44,7 @@ void shouldReturnLastValue() { historyRepository.save(entity); } - BalanceHistoryEntity historyEntity = historyRepository.findTop1ByAccountIdOrderByTimeDesc(ACCOUNT_ID); + BalanceHistoryEntity historyEntity = historyRepository.findFirstByAccountIdOrderByTimeDesc(ACCOUNT_ID).get(); assertEquals(19, historyEntity.getBalance()); } @@ -64,7 +64,7 @@ void shouldReturnBalanceBeforeDate() { } } - BalanceHistoryEntity balanceHistory = historyRepository.findLastBalanceBeforeTime(ACCOUNT_ID, time.minusDays(2).withHour(0).withMinute(0).withSecond(0)).get(); + BalanceHistoryEntity balanceHistory = historyRepository.findFirstByAccountIdAndTimeLessThanOrderByTimeDesc(ACCOUNT_ID, time.minusDays(2).withHour(0).withMinute(0).withSecond(0)).get(); assertEquals(19800, balanceHistory.getBalance()); } diff --git a/src/test/java/com/home/budgetbot/bank/repository/TestBalanceHistoryRepository.java b/src/test/java/com/home/budgetbot/bank/repository/TestBalanceHistoryRepository.java deleted file mode 100644 index b50c4e2..0000000 --- a/src/test/java/com/home/budgetbot/bank/repository/TestBalanceHistoryRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.home.budgetbot.bank.repository; - -import io.micronaut.context.annotation.Replaces; -import io.micronaut.data.jdbc.annotation.JdbcRepository; -import io.micronaut.data.model.query.builder.sql.Dialect; - -@JdbcRepository(dialect = Dialect.H2) -@Replaces(BalanceHistoryRepository.class) -public interface TestBalanceHistoryRepository extends BalanceHistoryRepository { - void deleteAll(); -} diff --git a/src/test/java/com/home/budgetbot/bank/service/MonobankServiceTest.java b/src/test/java/com/home/budgetbot/bank/service/MonobankServiceTest.java index 51bf5d7..c6e4771 100644 --- a/src/test/java/com/home/budgetbot/bank/service/MonobankServiceTest.java +++ b/src/test/java/com/home/budgetbot/bank/service/MonobankServiceTest.java @@ -1,7 +1,7 @@ package com.home.budgetbot.bank.service; import com.home.budgetbot.bank.repository.BalanceHistoryEntity; -import com.home.budgetbot.bank.repository.TestBalanceHistoryRepository; +import com.home.budgetbot.bank.repository.BalanceHistoryRepository; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import jakarta.inject.Inject; import org.junit.jupiter.api.BeforeEach; @@ -19,7 +19,7 @@ class MonobankServiceTest { public static final String ACCOUNT_ID = "TEST"; @Inject - TestBalanceHistoryRepository historyRepository; + BalanceHistoryRepository historyRepository; @Inject BankService bankService; diff --git a/src/test/java/com/home/budgetbot/bank/webhook/WebhookControllerTest.java b/src/test/java/com/home/budgetbot/bank/webhook/WebhookControllerTest.java index ef6734a..b413eb5 100644 --- a/src/test/java/com/home/budgetbot/bank/webhook/WebhookControllerTest.java +++ b/src/test/java/com/home/budgetbot/bank/webhook/WebhookControllerTest.java @@ -13,7 +13,7 @@ import com.home.budgetbot.bank.BalanceChangeEventListener; import com.home.budgetbot.bank.event.BalanceChangeEvent; -import com.home.budgetbot.bank.repository.TestBalanceHistoryRepository; +import com.home.budgetbot.bank.repository.BalanceHistoryRepository; import java.time.Duration; import java.util.List; @@ -55,7 +55,7 @@ class WebhookControllerTest { BalanceChangeEventListener eventListener; @Inject - TestBalanceHistoryRepository historyRepository; + BalanceHistoryRepository historyRepository; @BeforeEach void setUp() { diff --git a/src/test/java/com/home/budgetbot/bot/service/BudgetServiceTest.java b/src/test/java/com/home/budgetbot/bot/service/BudgetServiceTest.java index bf1b9fd..49b78c7 100644 --- a/src/test/java/com/home/budgetbot/bot/service/BudgetServiceTest.java +++ b/src/test/java/com/home/budgetbot/bot/service/BudgetServiceTest.java @@ -1,6 +1,6 @@ package com.home.budgetbot.bot.service; -import com.home.budgetbot.bank.repository.TestBalanceHistoryRepository; +import com.home.budgetbot.bank.repository.BalanceHistoryRepository; import com.home.budgetbot.bank.service.BankService; import com.home.budgetbot.bot.service.model.BudgetChangeReportModel; import com.home.budgetbot.bot.service.model.BudgetConfigModel; @@ -38,7 +38,7 @@ class BudgetServiceTest { BudgetService budgetService; @Inject - TestBalanceHistoryRepository historyRepository; + BalanceHistoryRepository historyRepository; @MockBean(ConfigService.class) ConfigService configServiceMock() { diff --git a/src/test/resources/application-integration.yaml b/src/test/resources/application-integration.yaml index bae7091..7e874ec 100644 --- a/src/test/resources/application-integration.yaml +++ b/src/test/resources/application-integration.yaml @@ -4,11 +4,13 @@ micronaut: datasources: default: - url: "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1" + # Tests run the SAME curated schema as production (db/schema.sql) so they + # validate the real schema, not a lossy DDL projection generated from entities. + url: "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT FROM 'classpath:db/schema.sql'" username: admin password: admin driver-class-name: org.h2.Driver - schema-generate: CREATE + schema-generate: NONE dialect: H2 monobank: From 3d994fad5e18719cf98d459ccebccfd7c92a752b Mon Sep 17 00:00:00 2001 From: Yurii Kucherenko Date: Sun, 14 Jun 2026 18:49:38 +0300 Subject: [PATCH 4/4] ci: build non-master branches to :test tag, master to :latest+:sha Any branch except master now builds (after tests pass) to ghcr.io/fyfar/budgetbot:test, so a new version can be tested on the server by swapping bot.image to :test and reverted by swapping back to :latest. Adds a concurrency guard to cancel superseded builds on the same branch. --- .github/workflows/deploy.yml | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 13d5424..df2be38 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,12 @@ name: Build and Deploy on: push: - branches: [master] + branches: ['**'] # master -> prod tags; every other branch -> :test + +# Cancel an in-progress build when the same branch is pushed again (saves CI minutes). +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build-and-push: @@ -22,6 +27,7 @@ jobs: distribution: 'temurin' cache: maven + # Tests run before the image is pushed, so a red branch never produces a :test image. - name: Run tests run: mvn clean test @@ -35,13 +41,27 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # master -> :latest + :sha- (production; the tag Watchtower follows) + # any other branch -> :test (opt-in manually on the server) + - name: Compute image tags + id: meta + run: | + if [ "${{ github.ref }}" = "refs/heads/master" ]; then + { + echo "tags<> "$GITHUB_OUTPUT" + else + echo "tags=ghcr.io/fyfar/budgetbot:test" >> "$GITHUB_OUTPUT" + fi + - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true - tags: | - ghcr.io/fyfar/budgetbot:latest - ghcr.io/fyfar/budgetbot:sha-${{ github.sha }} + tags: ${{ steps.meta.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max