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.
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 -> { });
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: