Skip to content

Data Storage

UltiTools encapsulates a data storage API that supports MySQL database, SQLite database (since 6.1.0), and JSON file storage. Data storage is transparent to developers, and UltiTools will determine which storage method to use based on the server owner's configuration.

All you need is an entity class. CRUD operations will be done automatically by UltiTools.

Try not to nest objects

Since the API is still under development, there may be problems when dealing with complex objects, so try not to nest objects.

Create Entity Class

BaseDataEntity

Create a class that extends BaseDataEntity<String>, and use the @Table and @Column annotations to mark your entity class.

java
package com.ultikits.docs.data;

import com.ultikits.ultitools.abstracts.data.BaseDataEntity;
import com.ultikits.ultitools.annotations.Column;
import com.ultikits.ultitools.annotations.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@Table("user_data")
public class UserData extends BaseDataEntity<String> {
    @Column("player_name")
    private String playerName;

    @Column(value = "balance", type = "FLOAT")
    private double balance;
}

@Table is used to mark the data set corresponding to the class, and @Column is used to mark the field corresponding to the field of the data set of the class.

@Data, @Builder, @NoArgsConstructor, @AllArgsConstructor, @EqualsAndHashCode are Lombok annotations, which are used to automatically generate getter, setter, builder, equals, hashCode methods.

Migration from AbstractDataEntity

Starting from v6.2.0, DataOperator, Query, and UltiToolsPlugin.getDataOperator() require entities to extend BaseDataEntity<String> instead of AbstractDataEntity. If your entity still extends AbstractDataEntity, change it to BaseDataEntity<String>.

BaseDataEntity<String> provides lifecycle hooks for insert/update/delete/load events:

MethodDescription
onCreate()Called before the entity is first persisted
onUpdate()Called before the entity is updated
onDelete()Called before the entity is deleted
onLoad()Called after the entity is loaded from the data store
validate()Returns true if the entity is valid
isNew()Returns true if the entity has no ID
copyWithoutId()Creates a copy of the entity without the ID. The entity class must implement Cloneable.

Lifecycle hooks are invoked by your code, not by the operator

onCreate(), onUpdate(), onDelete() and onLoad() are declared on BaseDataEntity, but no read or write path in the JSON, MySQL or SQLite operators calls them, so an entity that overrides them stores exactly the same data as one that does not. Call the hook yourself around the operation, entity.onCreate(); op.insert(entity); before a write and entity.onLoad(); on what a read returns: all four methods are public. Having the operators invoke the hooks is tracked in issue #194.

AuditableDataEntity

For entities that require audit tracking of creation and modification, use AuditableDataEntity:

java
package com.ultikits.docs.data;

import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.abstracts.data.AuditableDataEntity;
import com.ultikits.ultitools.abstracts.data.BaseDataEntity;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.interfaces.DataOperator;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.bukkit.entity.Player;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@Table("audit_log")
public class AuditEntry extends AuditableDataEntity<String> {
    @Column("action")
    private String action;
    @Column("details")
    private String details;
}

AuditableDataEntity<String> extends BaseDataEntity<String> and automatically manages:

FieldTypeDescription
createdAtLocalDateTimeEntity creation timestamp (auto-set in onCreate())
updatedAtLocalDateTimeLast modification timestamp (updated in onUpdate())
createdByUUIDUser ID who created the entity (from thread-local context)
updatedByUUIDUser ID who last modified the entity (from thread-local context)

All four fields are pre-configured with @Column annotations and do not need to be declared in subclasses.

The four audit columns stay NULL after an insert

Because the operators do not call the lifecycle hooks, onCreate() and onUpdate() never run, so created_at, updated_at, created_by and updated_by are never written, wasModified() always returns false, and getAge() and getTimeSinceUpdate() always return null. Set the thread context with AuditableDataEntity.setCurrentUser(uuid), call entity.onCreate() or entity.onUpdate() before the write, and clear the context in a finally block: without the context the two by fields stay null even when the hook runs. Having the operators invoke the hooks is tracked in issue #194.

User Context Management

To track which user performed operations, set the current user before database operations:

java
import com.ultikits.ultitools.abstracts.data.AuditableDataEntity;

UUID currentUserId = player.getUniqueId();
AuditableDataEntity.setCurrentUser(currentUserId);

try {
    DataOperator<AuditEntry> op = plugin.getDataOperator(AuditEntry.class);
    AuditEntry entry = AuditEntry.builder()
        .action("login")
        .details("Player logged in from 192.168.1.1")
        .build();
    op.insert(entry);  // createdBy and updatedBy automatically set
} finally {
    AuditableDataEntity.clearCurrentUser();
}

Always clear the context

Use a try-finally block to ensure clearCurrentUser() is called, otherwise the ThreadLocal context persists across requests and may leak user identity.

Utility Methods

AuditableDataEntity provides convenience methods for time-based queries:

MethodReturnsDescription
getAge()Duration or nullTime elapsed since entity creation
getTimeSinceUpdate()Duration or nullTime elapsed since last modification
wasModified()booleanWhether entity was modified after creation

Example usage:

java
AuditEntry entry = op.getById("some-id");
if (entry.wasModified()) {
    System.out.println("Modified " + entry.getTimeSinceUpdate().getSeconds() + " seconds ago");
}

Null-safety

getAge() and getTimeSinceUpdate() return null if the entity has not been persisted (missing createdAt or updatedAt). Always check for null before calling methods on the returned Duration.

@Table

@Table annotation has a value attribute, which is used to specify the name of the data set corresponding to the class.

@Column

@Column annotation has two attributes, value attribute is used to specify the column of the data set corresponding to the field, type attribute is used to specify the type of the column of the data set corresponding to the field.

The default value of the type attribute is VARCHAR(255).

Available types can be found in MySQL Data Types.

CRUD Operations

UltiTools encapsulates a semantic CRUD operation API. You only need to call the corresponding method to complete the addition, deletion, modification and query of the data.

DataOperator

DataOperator is used for data operations.

In the main class that inherits UltiToolsPlugin, there is a getDataOperator method to get the data operator.

You need to get the instance of the module main class, and then call the getDataOperator method.

java
package com.ultikits.docs.data;

import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.entities.WhereCondition;
import com.ultikits.ultitools.interfaces.DataOperator;

import java.util.List;

public class UserDataService {

    public void save(UltiToolsPlugin plugin, UserData data) {
        DataOperator<UserData> operator = plugin.getDataOperator(UserData.class);
        operator.insert(data);
    }

    public List<UserData> findByName(UltiToolsPlugin plugin, String name) {
        DataOperator<UserData> operator = plugin.getDataOperator(UserData.class);
        return operator.getAll(
                WhereCondition.builder().column("player_name").value(name).build()
        );
    }
}

WARNING

DataOperator is not thread-safe. Please get DataOperator when you need it, and do not try to save DataOperator object.

Insert

java
SomeEntity entity = SomeEntity.builder()
    .name("test")
    .something(42.0)
    .build();
dataOperator.insert(entity);

Query

Using WhereCondition:

java
List<SomeEntity> list = dataOperator.getAll(
    WhereCondition.builder()
        .column("name")
        .value("test")
        .build()
);

Or get a single entity by ID:

java
SomeEntity entity = dataOperator.getById("some-id");

Get all entities:

java
List<SomeEntity> all = dataOperator.getAll();

Pagination:

java
List<SomeEntity> page = dataOperator.page(1, 10); // page 1, 10 per page

page() returns an empty list on the JSON backend

On the JSON backend page(int, int) forwards to getAll(WhereCondition...), whose zero-length branch returns an empty list, while the same call on MySQL or SQLite builds a plain LIMIT ? OFFSET ? and returns the rows, so one module gives two different results depending on the configured backend. Take getAll() and slice the result with subList when you need a page that behaves the same on every backend: page(1, 10, WhereCondition.empty()) is not a substitute, because the relational operators do not filter empty conditions and would emit WHERE null = ?. Aligning page and exist with getAll on empty conditions is tracked in issue #193.

Query DSL

Starting from v6.2.0, you can use the fluent Query DSL for more readable queries:

java
SomeEntity entity = dataOperator.query()
    .where("name").eq("test")
    .first();

Update

Update a single field:

java
dataOperator.update("name", "newName", entityId);

Update by entity object:

java
try {
    entity.setName("newName");
    dataOperator.update(entity);
} catch (IllegalAccessException e) {
    // handle or rethrow
}

This overload declares throws IllegalAccessException, so the calling method must declare or catch it.

Delete

Delete by ID:

java
dataOperator.delById(entityId);

Delete by condition:

java
dataOperator.del(
    WhereCondition.builder()
        .column("name")
        .value("test")
        .build()
);

WhereCondition

WhereCondition is used to specify the query condition.

java
WhereCondition.builder().column("somecol").value(someval).build();

column is used to specify the column to be queried, and value is used to specify the value to be queried.

Transactions

For operations that need to succeed or fail together, see the Transactions guide.

Only the JSON backend rolls this block back

The MySQL and SQLite operators are constructed without a transaction manager, and transaction(...) runs the callable directly when none is set, so the connection stays in autocommit and each insert below is committed on its own. Use the JSON backend when this block has to be atomic, or take your own JDBC connection, turn off autocommit and commit or roll back yourself: the Transactions guide describes both. Wiring the transaction manager into the relational operators is tracked in issue #307.

java
dataOperator.transaction(() -> {
    dataOperator.insert(entity1);
    dataOperator.insert(entity2);
    // Both inserted or none
});

Contributors

The avatar of contributor named as Ling Bao Ling Bao
The avatar of contributor named as Claude Fable 5.1 Claude Fable 5.1

Released under the MIT License.