Dynamic Database
Полное руководство по Dynamic Database 1.0.8: архитектура, контракты, запросы, relations, include graphs, миграции и production-паттерны.
Dynamic Database
Dynamic Database в NextLib это ORM-подобный слой поверх JDBC/HikariCP, где вы работаете Java-сущностями, а не ручным SQL в каждом классе.
Если коротко, поток такой:
- Собираете
DatabaseConfig. - Регистрируете клиент в
DatabaseManager. - Создаёте
DynamicDatabase. - Регистрируете
DynamicTable<T>для сущности. - Выполняете CRUD fluent-методами.
- При необходимости используете relation include-графы.
- Контролируете изменения схемы через 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;
}
Обязательные правила
- Должен быть конструктор, покрывающий все поля.
- Порядок параметров должен соответствовать маппингу полей.
- Для nullable колонок используйте reference-типы (
Integer,Long), не примитивы. - Для 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:
- Инспекция metadata класса.
- Генерация/проверка SQL схемы.
CREATE TABLE IF NOT EXISTS.- Для relations возможно создание join-таблиц.
- При включенной автоматике проверка недостающих колонок.
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 удобен, но может создавать много дополнительных запросов.
Практика:
- Для списков используйте небольшой
take(limit). - Включайте только нужные связи.
- Для отчётов/аналитики используйте прямой 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.
Что делать
- Добавьте индексы на часто фильтруемые поля.
- Всегда ограничивайте большие выдачи
take(limit). - Для тяжёлых выборок делайте пагинацию.
- Счётчики и агрегаты выполняйте прямым SQL.
11. Сопоставление типов Java -> SQL
| Java тип | SQL (обобщённо) | Комментарий |
|---|---|---|
UUID | TEXT / UUID | зависит от диалекта |
String | TEXT | универсально |
Integer | INT / INTEGER | nullable через wrapper |
Long | BIGINT / INTEGER | timestamps, counters |
Double | DOUBLE / REAL | избегайте для валюты |
Boolean | BOOLEAN / INTEGER | SQLite часто хранит как 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 работал стабильно даже на крупных серверах.