Astral Realms Documentation Help

Database (ORM)

AstralCore ships a lightweight annotation-based ORM on top of HikariCP. It auto-generates SQL for standard CRUD operations; no XML or query strings required.

Connection Configuration

See database.properties for the full connection pool reference.

Defining an Entity

Annotate a class with @Entity and mark fields with the ORM annotations:

import com.astralrealms.core.storage.annotation.*; import com.astralrealms.core.model.Unique; @Entity("players") // table name public class MinecraftPlayer implements Unique { @Id private UUID uniqueId; // primary key @Column("player_name") private String name; @Column("server_id") @NonUpdatable // never included in UPDATE statements private String serverId; @CreatedAt private long createdAt; // auto-set on INSERT @UpdatedAt private long updatedAt; // auto-set on INSERT and UPDATE @Transient // not persisted private transient boolean online; // getters, constructors … }

Annotation Reference

Annotation

Target

Description

@Entity(value)

Class

Marks a class as a persistent entity. value is the table name.

@Id

Field

Primary key column. Should be UUID.

@Column(value)

Field

Maps the field to a column name. Omit to use the field name as-is.

@CreatedAt

Field (long)

Automatically set to System.currentTimeMillis() on INSERT.

@UpdatedAt

Field (long)

Automatically set on INSERT and every UPDATE.

@NonUpdatable

Field

Excluded from generated UPDATE statements.

@Transient

Field

Excluded from all SQL (not stored).

CrudRepository

CrudRepository<T extends Unique> provides auto-generated async SQL for the annotated entity:

CrudRepository<MinecraftPlayer> repo = new CrudRepository<>(databaseService, MinecraftPlayer.class);

Basic CRUD

// Find by primary key repo.findById(uuid).thenAccept(opt -> opt.ifPresent(this::cache)); // Find all rows repo.findAll().thenAccept(players -> { }); // Insert repo.insert(player); // Update repo.update(player); // Upsert (insert or update) repo.upsert(player); // Save — an alias for upsert; the future resolves with the persisted entity, never null repo.save(player).thenAccept(saved -> { }); // Delete repo.delete(uuid); // Existence check repo.exists(uuid).thenAccept(exists -> { }); // Count repo.count().thenAccept(n -> { });

Query by Column

// Find one by a specific column value repo.findByColumn("player_name", "Steve") .thenAccept(opt -> { }); // Find all matching repo.findAllByColumn("server_id", "lobby-1") .thenAccept(list -> { }); // Multiple column conditions (AND) repo.findByColumns(Map.of("server_id", "lobby-1", "player_name", "Steve")) .thenAccept(opt -> { }); // Delete by column repo.deleteByColumn("server_id", "lobby-1");

Custom SQL

repo.findOne("SELECT * FROM players WHERE lower(player_name) = ?", List.of("steve")) .thenAccept(opt -> { }); repo.findMany("SELECT * FROM players WHERE server_id = ? ORDER BY created_at", List.of("lobby-1")) .thenAccept(list -> { });

Batch Operations

// Batch insert repo.insertBatch(List.of(player1, player2, player3)); // Batch update repo.updateBatch(players); // Batch upsert repo.upsertBatch(players);

Pagination

Pageable pageable = Pageable.of(0, 20); // page 0, 20 rows per page repo.findAll(pageable).thenAccept(page -> { List<MinecraftPlayer> content = page.content(); int total = page.totalElements(); int pages = page.totalPages(); });

DatabaseService

DatabaseService provides the underlying connection pool and transaction support:

DatabaseService db = AstralCore.get().database(); // Async query returning a result db.supply(connection -> { PreparedStatement stmt = connection.prepareStatement("SELECT COUNT(*) FROM players"); ResultSet rs = stmt.executeQuery(); rs.next(); return rs.getInt(1); }).thenAccept(count -> { }); // Async void operation db.run(connection -> { connection.createStatement().execute("TRUNCATE temp_data"); }); // Transaction — auto-rollback on exception db.transaction(connection -> { // multiple operations in one transaction insertSomething(connection); updateSomething(connection); }); // Synchronous (blocking) int count = db.runSync(connection -> { // … });

CachedRepository

CachedRepository<T> combines CrudRepository and CacheRepository into a two-tier read-through pattern:

public class PlayerRepository extends CachedRepository<MinecraftPlayer> { public PlayerRepository(DatabaseService db, CacheService cache) { super(db, cache, "player", MinecraftPlayer.class, Duration.ofMinutes(10)); } }

On findById: checks Redis first; on cache miss, queries the database and caches the result automatically. On save: writes to the database and updates the cache; the future resolves with the saved entity, never null. On delete: removes from both layers.

A cache write that cannot serialize the entity — a Jackson failure, or a mapper that produces an empty string — completes the returned future exceptionally instead of reporting a silent success. Because findById, findByColumns, findOne, findMany, save and update all compose on that write, a serialization problem surfaces to the caller rather than dropping the entity from the cache with no trace.

Schema Changes Require a Manual Migration

DatabaseService runs the bundled schema.sql on startup, splitting it on ; and executing each statement in order. Every table is declared with CREATE TABLE IF NOT EXISTS, so an existing database silently keeps its old table definition: columns and table-level constraints added inside a CREATE TABLE block never reach it. Statements that stand on their own — CREATE INDEX IF NOT EXISTS, ALTER TABLE — do execute on every restart, and a statement that throws aborts the remainder of the script for that startup, so anything unguarded must stay idempotent.

Worked example — the last_seen_at column and the sessions → players foreign key. Both were added to the CREATE TABLE blocks, so both need applying by hand once per environment (MariaDB syntax):

ALTER TABLE players ADD COLUMN last_seen_at TIMESTAMP DEFAULT NULL; -- The FK fails while sessions holds rows whose player_id has no matching player. That is likely: -- the previous inline column-level REFERENCES was parsed and silently ignored by MariaDB, so the -- constraint was never actually enforced. Clean the orphans first. DELETE FROM sessions WHERE player_id NOT IN (SELECT id FROM players); ALTER TABLE sessions ADD CONSTRAINT fk_sessions_player FOREIGN KEY (player_id) REFERENCES players (id) ON DELETE CASCADE; -- Optional backfill: treat the last recorded login as the last time the player was seen. UPDATE players SET last_seen_at = last_login_at WHERE last_seen_at IS NULL;

The accompanying CREATE INDEX IF NOT EXISTS fk_sessions_player ON sessions (player_id) needs no manual step — it is a standalone statement and applies on the next restart.

Supported Databases

Upsert behaviour is automatically adapted to the detected database engine:

Database

Upsert SQL

MariaDB / MySQL

INSERT … ON DUPLICATE KEY UPDATE

PostgreSQL / H2

INSERT … ON CONFLICT DO UPDATE

Last modified: 03 September 2026