Skip to content

Dynamic Database

Полное руководство по Dynamic Database 1.0.8: архитектура, контракты, запросы, relations, include graphs, миграции и production-паттерны.

Обновлено: 01 янв. 1980 г.Чтение: ~7 мин

Dynamic Database

Dynamic Database в NextLib это ORM-подобный слой поверх JDBC/HikariCP, где вы работаете Java-сущностями, а не ручным SQL в каждом классе.

Если коротко, поток такой:

  1. Собираете DatabaseConfig.
  2. Регистрируете клиент в DatabaseManager.
  3. Создаёте DynamicDatabase.
  4. Регистрируете DynamicTable<T> для сущности.
  5. Выполняете CRUD fluent-методами.
  6. При необходимости используете relation include-графы.
  7. Контролируете изменения схемы через auto/ручные миграции.

Ниже весь процесс максимально подробно.

1. Архитектура слоя данных

Уровень 1: DatabaseConfig

Immutable-конфиг подключения.

  • тип БД (MYSQL, POSTGRESQL, SQLITE),
  • адрес/база/логин/пароль или путь к файлу,
  • свойства пула (maximumPoolSize, connectionTimeout и др.).

Уровень 2: DatabaseManager

Реестр именованных datasource-клиентов.

  • register(name, config) поднимает datasource,
  • getDefault() даёт дефолтный клиент,
  • close() закрывает все пулы.

Уровень 3: DatabaseClient

Низкоуровневый API выполнения SQL.

  • query, queryOne, execute, executeBatch, транзакции.

Уровень 4: DynamicDatabase и DynamicTable<T>

Высокоуровневый слой сущностей.

  • отражение Java-полей в SQL-колонки,
  • auto create table,
  • fluent where/update/delete,
  • include relations.

2. Подключение: контракт и ошибки

PostgreSQL пример

DatabaseManager manager = new DatabaseManager();

DatabaseConfig config = DatabaseConfig.builder(DatabaseType.POSTGRESQL)
    .host("localhost")
    .port(5432)
    .database("nextlib")
    .username("app")
    .password("secret")
    .property("maximumPoolSize", "12")
    .property("minimumIdle", "2")
    .property("connectionTimeout", "30000")
    .property("maxLifetime", "1800000")
    .build();

DatabaseClient client = manager.register("main", config);

SQLite пример

DatabaseConfig sqlite = DatabaseConfig.builder(DatabaseType.SQLITE)
    .file(plugin.getDataFolder() + "/database.db")
    .property("maximumPoolSize", "4")
    .build();

Что обязательно

  • Для MYSQL/POSTGRESQL: host, database, username, password.
  • Для SQLITE: file.

Типичные исключения на старте

  • ConfigurationException: Missing JDBC driver:
    • не подключен драйвер БД в runtime.
  • ConfigurationException: Failed to configure HikariCP pool:
    • некорректные свойства пула,
    • недоступен хост/порт,
    • неверные креды.

3. Сущности: как правильно проектировать

Минимальный пример

@AllArgsConstructor
@Getter
public class PlayerEntity {
    @PrimaryKey
    private final UUID playerId;

    private final String nickname;
    private final Integer coins;
    private final Long lastLoginAt;
}

Обязательные правила

  1. Должен быть конструктор, покрывающий все поля.
  2. Порядок параметров должен соответствовать маппингу полей.
  3. Для nullable колонок используйте reference-типы (Integer, Long), не примитивы.
  4. Для primary key лучше использовать стабильный бизнес-id (UUID), а не mutable поля.

Нежелательные практики

  • Добавлять в сущность чисто runtime-поля (кэши/сервисы).
  • Использовать нестабильные значения как primary key.
  • Менять тип колонки без миграционного плана.

Новые аннотации в текущем коде библиотеки

В актуальной реализации Dynamic Database поддерживаются дополнительные аннотации валидации, индексации и конвертации:

  • @NotNull
  • @Size(min, max)
  • @Min(value)
  • @Max(value)
  • @Pattern(regex)
  • @Email
  • @Index(fields = {...}) / @Indexes
  • @Unique(fields = {...}) / @Uniques
  • @Convert(MyConverter.class)

Что это даёт на практике

  • Перед create/update/upsert значения валидируются на уровне entity metadata.
  • Индексы и уникальные ограничения могут быть описаны декларативно на сущности.
  • Через @Convert можно хранить кастомные Java-типы в стандартных SQL типах.

Пример:

@Index(fields = {"nickname"})
@Unique(fields = {"email"})
public class PlayerEntity {
    @PrimaryKey
    private final UUID playerId;

    @NotNull
    @Size(min = 3, max = 16)
    private final String nickname;

    @Email
    private final String email;

    @Convert(JsonMapConverter.class)
    private final Map<String, Integer> stats;
}

Конвертер:

public final class JsonMapConverter implements ValueConverter<Map<String, Integer>, String> {
    @Override
    public Class<String> databaseType() {
        return String.class;
    }

    @Override
    public String toDatabase(Map<String, Integer> value) {
        return value == null ? null : serializeToJson(value);
    }

    @Override
    public Map<String, Integer> fromDatabase(String value) {
        return value == null ? Map.of() : parseFromJson(value);
    }
}

4. Регистрация таблиц и жизненный цикл

DynamicDatabase db = new DynamicDatabase(client)
    .withAutoMigrations(plugin);

DynamicTable<PlayerEntity> players = db.register("players", PlayerEntity.class);

Что происходит при register:

  1. Инспекция metadata класса.
  2. Генерация/проверка SQL схемы.
  3. CREATE TABLE IF NOT EXISTS.
  4. Для relations возможно создание join-таблиц.
  5. При включенной автоматике проверка недостающих колонок.

5. CRUD максимально детально

Create

players.create(new PlayerEntity(
    player.getUniqueId(),
    player.getName(),
    100,
    System.currentTimeMillis()
));

Гарантии:

  • значения биндим через prepared statements,
  • SQL injection на уровне value-параметров отсутствует.

Batch-вставка:

int inserted = players.createMany(batch);

Когда использовать batch:

  • массовая миграция,
  • импорт архивных данных,
  • периодические синхронизации.

Read

Получить одну запись

Optional<PlayerEntity> one = players.findFirst()
    .where("playerId", playerId)
    .execute();

Получить множество с сортировкой

List<PlayerEntity> top = players.findMany()
    .where("coins", QueryOperator.GREATER_THAN, 0)
    .orderBy("coins", true)
    .take(50)
    .skip(0)
    .execute();

Важно: skip(offset) требует take(limit), иначе будет исключение.

Подсчёт

long count = players.count(Map.of("nickname", "chi2l3s"));

Update

Builder-подход

int updated = players.update()
    .set("coins", 150)
    .set("lastLoginAt", System.currentTimeMillis())
    .where("playerId", playerId)
    .execute();

Map-подход

int updatedMany = players.updateMany(
    Map.of("nickname", "Tester"),
    Map.of("coins", 1000)
);

Контракт безопасности

  • update(where, data) рассчитан на единичную запись.
  • Если затронуто >1 строки, бросается DatabaseException.

Это полезно, когда вы логически ожидаете уникальное совпадение.

Delete

int deleted = players.delete(Map.of("playerId", playerId));

Аналогично update:

  • единичный delete защищает от случайного удаления множества строк.

Массовое удаление:

int purged = players.deleteMany(Map.of("nickname", "obsolete"));

Upsert

PlayerEntity result = players.upsert(
    Map.of("playerId", playerId),
    new PlayerEntity(playerId, "new", 0, System.currentTimeMillis()),
    Map.of("lastLoginAt", System.currentTimeMillis())
);

Когда это удобно:

  • lazy-инициализация записи игрока,
  • идемпотентный апдейт состояния.

6. Where API: все основные варианты

Равенство и сравнения

.where("coins", 100)
.where("coins", QueryOperator.GREATER_THAN, 100)
.where("coins", QueryOperator.LESS_THAN_OR_EQUALS, 5000)

Комбинации условий

.where("coins", QueryOperator.GREATER_THAN, 100)
.orWhere("nickname", "Admin")
.whereNot("nickname", "BannedName")

Строковые фильтры

.whereLike("nickname", "Pro%")
.whereStartsWith("nickname", "Mr")
.whereEndsWith("nickname", "_YT")
.whereContains("nickname", "fox")

Множества и диапазоны

.whereIn("nickname", "a", "b", "c")
.whereBetween("coins", 100, 1000)

Null-проверки

.whereIsNull("lastLoginAt")
.whereIsNotNull("lastLoginAt")

Контракт:

  • IN требует минимум 1 значение.
  • BETWEEN требует ровно 2 значения.
  • Для операторов, не совместимых с null, будет исключение.

7. Relations и include-графы

DynamicTable умеет загружать связи через include(...) и возвращать EntityGraph<T>.

Optional<EntityGraph<PlayerEntity>> graph = players.findFirst()
    .where("playerId", playerId)
    .include("profile", "guild", "achievements")
    .executeGraph();

Что внутри EntityGraph:

  • корневой объект,
  • map relationName -> загруженная relation (object/list).

Важный компромисс

include удобен, но может создавать много дополнительных запросов.

Практика:

  1. Для списков используйте небольшой take(limit).
  2. Включайте только нужные связи.
  3. Для отчётов/аналитики используйте прямой SQL через DatabaseClient.

8. Авто-миграции и ручные миграции

Auto migrations

withAutoMigrations(plugin) включает менеджер автогенерации SQL по изменениям metadata.

Чаще всего это:

  • добавление новых колонок,
  • создание недостающих таблиц.

Ручные миграции

database.migrations().migrate(List.of(
    new SqlMigration(
        "202603040001__add_rank",
        "Add rank column to players",
        List.of("ALTER TABLE players ADD COLUMN rank TEXT")
    )
));

Когда ручная миграция обязательна:

  • перенос данных между колонками,
  • изменение типа с преобразованием,
  • сложные индексы/constraints.

9. Транзакции и консистентность

Если у вас несколько запросов, которые должны примениться вместе, используйте транзакцию на уровне DatabaseClient.

Пример сценария:

  • списать баланс,
  • создать запись покупки,
  • обновить статистику.

Если второй шаг упал, первый должен откатиться.

Рекомендация:

  • транзакции держите короткими,
  • не включайте в них network I/O,
  • логируйте причины rollback.

10. Производительность и индексы

Симптомы проблем

  • рост latency у findMany,
  • высокая нагрузка CPU у СУБД,
  • блокировки при массовых update/delete.

Что делать

  1. Добавьте индексы на часто фильтруемые поля.
  2. Всегда ограничивайте большие выдачи take(limit).
  3. Для тяжёлых выборок делайте пагинацию.
  4. Счётчики и агрегаты выполняйте прямым SQL.

11. Сопоставление типов Java -> SQL

Java типSQL (обобщённо)Комментарий
UUIDTEXT / UUIDзависит от диалекта
StringTEXTуниверсально
IntegerINT / INTEGERnullable через wrapper
LongBIGINT / INTEGERtimestamps, counters
DoubleDOUBLE / REALизбегайте для валюты
BooleanBOOLEAN / INTEGERSQLite часто хранит как int
byte[]BLOB / BYTEAбинарные данные

12. Практические паттерны

Паттерн A: Player profile repository

  • один DynamicTable<PlayerEntity> на профиль,
  • все DB-вызовы инкапсулированы в PlayerRepository,
  • сервисы работают только через репозиторий.

Паттерн B: Idempotent player bootstrap

  • при входе игрока вызывается upsert,
  • повторный вызов не ломает данные,
  • startup проще и безопаснее.

Паттерн C: CQRS-lite

  • write: DynamicTable операции,
  • read-heavy отчёты: DatabaseClient и raw SQL.

13. Антипаттерны

  • Выполнять DB-запросы в каждом клике GUI без необходимости.
  • Хранить деньги в double.
  • Делать findMany без take на больших таблицах.
  • Полагаться на уникальность поля без DB-ограничений.
  • Игнорировать shutdown и не закрывать DatabaseManager.

14. Большой FAQ

Почему findUnique падает с множественным результатом?

Потому что контракт метода предполагает ровно 0 или 1 строку. Если в БД больше одной, это сигнал проблем с уникальностью данных.

Можно ли использовать Dynamic Database без auto migrations?

Да. Тогда схема контролируется полностью вручную.

Что выбрать для маленького плагина: SQLite или PostgreSQL?

  • SQLite: быстрее старт, проще деплой на один сервер.
  • PostgreSQL: лучше масштабирование, больше инструментов аналитики и администрирования.

Где держать SQL-миграции?

Обычно в каталоге плагина рядом с данными/ресурсами; важно версионировать и хранить в VCS.

15. Чеклист перед продом

  • Подключён JDBC-драйвер вашей БД.
  • DatabaseManager.close() вызывается в onDisable.
  • Есть индексы на hot-поля.
  • CRUD сценарии покрыты smoke-тестами.
  • Reload не ломает активные операции.
  • Ошибки БД логируются с контекстом запроса.

Этого достаточно, чтобы Dynamic Database работал стабильно даже на крупных серверах.