Class AbstractRelationalDataOperator<T extends BaseDataEntity<String>>

java.lang.Object
com.ultikits.ultitools.interfaces.impl.data.AbstractRelationalDataOperator<T>
Type Parameters:
T - the entity type
All Implemented Interfaces:
DataOperator<T>, RowCountingDelete
Direct Known Subclasses:
MysqlDataOperator, SQLiteDataOperator

public abstract class AbstractRelationalDataOperator<T extends BaseDataEntity<String>> extends Object implements DataOperator<T>, RowCountingDelete
Abstract base class for relational database data operators.

This class extracts common logic from MySQL and SQLite implementations, providing a unified implementation for CRUD operations. Only the database-specific SQL dialect differences need to be overridden.

Since:
6.2.0
Author:
wisdomme
  • Field Details

  • Constructor Details

    • AbstractRelationalDataOperator

      protected AbstractRelationalDataOperator(DataSource dataSource, Class<T> type)
      Creates a new data operator.
      Parameters:
      dataSource - the data source to use
      type - the entity class
  • Method Details

    • buildColumnTypes

      private static Map<String,Class<?>> buildColumnTypes(Class<?> entityType)
      Reflects the entity's @Column mappings once: their names are validateColumn(String)'s allow-list, and the field type behind each name is what orderedComparisonOperand(String, Class) receives. This mirrors the same reflected metadata buildColumnDefinitions(Class) and getColumnMappings() already read from the entity class — a dedicated pass over it, not a second scan of anything already cached. When two fields declare the same name, the first one reflected keeps the mapping.
      Parameters:
      entityType - the entity class
      Returns:
      an unmodifiable map from every @Column SQL name declared on entityType (including inherited fields, e.g. BaseDataEntity's id) to the Java type of its field
    • validateColumn

      private void validateColumn(String column)
      Refuses a column identifier that is not among the entity's own reflected @Column mappings, before it can be concatenated into SQL text (T-02-SQLI-1). Called from appendConditions(java.lang.StringBuilder, java.util.List<java.lang.Object>, com.ultikits.ultitools.entities.WhereCondition[], boolean) on behalf of all four relational WHERE builders.
      Parameters:
      column - the column name from a caller-supplied WhereCondition
      Throws:
      DataAccessException - if column is not a known column of type
    • setTransactionManager

      public void setTransactionManager(TransactionManager transactionManager)
      Sets the transaction manager for transaction-aware operations.
      Parameters:
      transactionManager - the transaction manager
    • currentTimeoutDeadlineNanos

      private Long currentTimeoutDeadlineNanos()
      Supplies queryRunner's per-statement query timeout deadline (D-10): the active transaction's remaining-time budget, or null when none is configured (no active transaction, or @Transactional(timeout=0), the default). Also used directly by applyStatementTimeout(java.sql.PreparedStatement) for the two PreparedStatement call sites that bypass queryRunner entirely. A method reference rather than a field capture, so every call reads transactionManager's *current* value -- this operator outlives any single transaction, and setTransactionManager(com.ultikits.ultitools.interfaces.TransactionManager) can swap the manager out.
      Returns:
      the active transaction's timeout deadline, or null if none is configured
    • applyStatementTimeout

      private void applyStatementTimeout(PreparedStatement statement) throws SQLException
      Applies the same per-statement query timeout queryRunner (a TimeoutAwareQueryRunner) would, to a PreparedStatement built directly against dataSource -- the two call sites in insertAll(java.util.List<T>) and updateAll(java.util.List<T>) that bypass QueryRunner entirely. A timeout wired only into queryRunner's path would leave these two batch operations silently ignoring @Transactional(timeout=), exactly the gap 02-RESEARCH.md warns about.
      Parameters:
      statement - the statement to apply the timeout to, already prepared
      Throws:
      SQLException - if the driver rejects the timeout value
    • initializeTable

      private void initializeTable()
      Initializes the database table.
    • backfillNullIds

      protected final int backfillNullIds(String rowIdColumn)
      Gives every row of this operator's table whose id is NULL an id that makes it addressable, and logs one line naming the table and the count when there were any (#546, maintainer decision of 2026-09-27).

      UltiTools-API 6.2.0 did not assign an id in insert(T), and SQLite's generated DDL (PRIMARY KEY (`id`) with no NOT NULL) accepted the NULL, so every row a module inserted without an id on that release was stored with none. Such a row can be read by any other column, but WHERE id = ? bound to null matches nothing, so every update or delete of it silently did nothing.

      Each row gets the id its entity reports through getId() (an entity may derive it from another column), or a new UUID when it reports none -- but only if an entity read back with that id in the id column reports it, since every lookup binds getId(). A row no written id would make addressable is left as it is, and one WARNING per table counts them by reason: a derived id that more than one row without an id reports (none of them is written, maintainer decision of 2026-09-29, the rule UltiEssentials' own repair applies), a derived id another row already holds, and a derived id that is null or a row that cannot be read as the entity. Only the id column is written, guarded by id IS NULL and keyed by the engine's row identifier, all rows in one transaction. A second start writes nothing and logs no INFO line; rows that were left are counted again. A failure rolls the whole repair back and is logged at SEVERE; the table is still usable, and an update or delete through a row without an id is refused rather than silently matching nothing.

      Called by the subclass whose engine can hold a NULL id (SQLiteDataOperator); MySQL rejects a NULL primary key, so its operator never calls it.

      Parameters:
      rowIdColumn - the engine's row-identifier pseudo-column (SQLite's _rowid_); a constant supplied by the subclass, never caller input
      Returns:
      the number of rows given an id
      Since:
      6.3.0
    • readRowsWithoutId

      private void readRowsWithoutId(Connection conn, String select, List<Object> rowIds, List<String> candidates, List<Boolean> fromEntity) throws SQLException
      Reads every row of select (row identifier first, then the table's columns) and decides which id, if any, would make it addressable.

      Every WHERE id = ? binds getId(), and an entity may derive getId() from another column -- UltiEssentials' and UltiKits' do -- so an id is useful only if an entity read back with it in the id column reports it. The id the entity already reports is tried first; otherwise a new UUID. A candidate that would not be reported back (the entity derives a null id), and a row that cannot be read as the entity at all, get null: writing anything to such a row would change nothing any lookup uses.

      Throws:
      SQLException
    • readEntity

      private T readEntity(AbstractRelationalDataOperator.RowMapper<T> mapper, ResultSet rs) throws SQLException
      Throws:
      SQLException
    • reportsAs

      private boolean reportsAs(T entity, String candidate)
      Whether entity, read back with candidate in its id column, reports candidate from getId() -- that is, whether writing it makes the row addressable.
    • isHeld

      private boolean isHeld(Connection conn, String held, String id) throws SQLException
      Whether a row of this table already holds id in its id column.
      Throws:
      SQLException
    • assignId

      private boolean assignId(Connection conn, String update, String id, Object rowId) throws SQLException
      Writes id into the row with this row identifier, on the backfill's own connection (so inside its transaction), if the row still has none. A value another row already holds violates the primary key; that is reported as false and the row is left without an id, since any other value would not be what its entity reports. The statement fails alone: SQLite rolls back only the failing statement, and the transaction continues.
      Throws:
      SQLException
    • isConstraintViolation

      private static boolean isConstraintViolation(SQLException e)
    • requireId

      private void requireId(Object id, String operation)
      Refuses an update or delete addressed by a null id (#546): WHERE id = ? bound to null matches no row, so the call used to return normally having changed nothing.
      Parameters:
      id - the id the caller addressed the row by
      operation - what was attempted, for the message
      Throws:
      DataAccessException - if id is null
    • getListHandler

      protected org.apache.commons.dbutils.ResultSetHandler<List<T>> getListHandler()
    • getRawListHandler

      private org.apache.commons.dbutils.ResultSetHandler<List<T>> getRawListHandler()
      The same row-materialization logic as getListHandler(), without firing onLoad(). Used internally by getById(java.lang.Object) (which fires onLoad() itself, exactly once, on the single entity it returns) and delById(java.lang.Object) (which needs the entity to fire onDelete() on, but deleting is not loading -- see 02-08-PLAN.md's delete-hook-path split).
    • getRowMapper

      Materialises one row as an entity without firing onLoad(): shared by getRawListHandler() and backfillNullIds(java.lang.String), so a row is read the same way whichever needs it. A column labelled BACKFILL_ROWID is skipped.
    • fetchRawById

      private T fetchRawById(Object id)
      Fetches a single row by id without firing onLoad(). Shared by getById(java.lang.Object) (which fires onLoad() on the result) and delById(java.lang.Object) (which fires onDelete() instead -- deleting is not loading).
    • sqlOperatorFor

      private String sqlOperatorFor(Comparison comparison, String orderedPlaceholder)
      Maps a Comparison to its SQL operator fragment (including the placeholder).

      Shared by all five relational WHERE builders (exist(WhereCondition...), getAll(WhereCondition...), page(int, int, WhereCondition...), del(WhereCondition...), updateIf(T, com.ultikits.ultitools.entities.WhereCondition...)) so a given Comparison means the same thing on every one of them, and on SimpleJsonDataOperator's JSON backend. There is no silent fallback to equality: an unhandled constant throws rather than being misread as EQUAL, since a silent default is exactly how this defect stayed invisible before.

      INCLUDE, STARTSWITH and ENDSWITH render LIKE ? ESCAPE '!': the value is matched literally as text (see likeWrappedValue(com.ultikits.ultitools.entities.WhereCondition)), the way SimpleJsonDataOperator uses contains, startsWith and endsWith. The escape character is the exclamation mark (LIKE_ESCAPE), not a backslash: MySQL's default sql_mode needs a doubled backslash inside the ESCAPE literal, and with NO_BACKSLASH_ESCAPES that doubled form is two characters and is rejected, while the exclamation mark means the same thing on SQLite, MySQL in either mode, and H2. No caller text enters the SQL. getLike(java.lang.String, java.lang.String, com.ultikits.ultitools.interfaces.DataOperator.LikeType) and Query#like are separate pattern operators and are not affected; EQUAL, GREATER and LESS are unchanged here (the column side of GREATER and LESS goes through orderedComparisonOperand(java.lang.String, java.lang.Class<?>), and their parameter side is orderedPlaceholder, which appendConditions(java.lang.StringBuilder, java.util.List<java.lang.Object>, com.ultikits.ultitools.entities.WhereCondition[], boolean) takes from orderedComparisonPlaceholder(java.lang.Class<?>, java.lang.Object)).

      Parameters:
      comparison - the comparison operator
      orderedPlaceholder - the parameter operand of GREATER and LESS; the other comparisons always use a plain ?
      Returns:
      the SQL fragment, e.g. " > ?"
    • likeWrappedValue

      private Object likeWrappedValue(WhereCondition condition)
      Builds the parameter to bind for a condition's value. For INCLUDE, STARTSWITH and ENDSWITH the value is matched literally as text: it is rendered with String.valueOf(Object) (so a null value and non-String values render exactly as before), then every escape character '!', % and _ in it is prefixed with '!' -- the escape character first, so the prefixes added for the other two are not escaped again -- and only then are the wildcards added that the comparison implies, in the same places SimpleJsonDataOperator matches: INCLUDE on both sides, STARTSWITH after the value, ENDSWITH before it. A backslash needs no escaping because sqlOperatorFor(com.ultikits.ultitools.entities.Comparison, java.lang.String) names '!' as the only escape character. EQUAL, GREATER and LESS pass the value through unchanged.
      Parameters:
      condition - the condition supplying the comparison and the raw value
      Returns:
      the value to bind as the SQL parameter
    • orderedComparisonOperand

      @Internal protected String orderedComparisonOperand(String column, Class<?> fieldType)
      Returns the SQL operand that a GREATER or LESS condition compares on the column side. The default returns column unchanged, so the database compares the stored value as it is declared. A dialect overrides it to compare the stored value as a number of the field's Java type when the declared column type would not, for example a numeric field left on the default VARCHAR(255) column.

      appendConditions(java.lang.StringBuilder, java.util.List<java.lang.Object>, com.ultikits.ultitools.entities.WhereCondition[], boolean) calls it only for GREATER and LESS, and only after validateColumn(String) has accepted column; EQUAL and the three LIKE comparisons never reach it. Its result is concatenated into the SQL text, so an override must build it only from column and constants, never from a condition's value or any other caller text.

      Parameters:
      column - an allow-listed @Column name of the entity
      fieldType - the Java type of the field mapped to column
      Returns:
      the SQL operand to compare, e.g. column or a cast of it
      Since:
      6.3.0
    • orderedComparisonPlaceholder

      @Internal protected String orderedComparisonPlaceholder(Class<?> fieldType, Object value)
      Returns the SQL operand that a GREATER or LESS condition compares on the parameter side: the placeholder ? the condition's value is bound to, or an expression around it. The default returns ?, so the database compares the value as bound. A dialect overrides it, together with orderedComparisonOperand(String, Class), when both sides must pass through the same conversion to compare the way the stored value reads back, for example a float field whose stored text keeps fewer digits than the bound value. The value is passed so an override can apply that conversion only to values it keeps the order of, for example a number whose double value is finite.

      appendConditions(java.lang.StringBuilder, java.util.List<java.lang.Object>, com.ultikits.ultitools.entities.WhereCondition[], boolean) calls it only for GREATER and LESS, after validateColumn(String) has accepted the column, and binds the condition's value to the result's one placeholder. Its result is concatenated into the SQL text, so an override must return exactly one ? and build the rest only from constants, never from a condition's value or any other caller text; it may only use the value to choose between such constant operands.

      Parameters:
      fieldType - the Java type of the field the condition compares
      value - the condition's value, which is bound to the placeholder; may be null
      Returns:
      the SQL operand holding exactly one ?, e.g. ? or a cast of it
      Since:
      6.3.0
    • appendConditions

      private void appendConditions(StringBuilder sql, List<Object> params, WhereCondition[] conditions, boolean skipEmpty)
      Appends a WHERE clause built from conditions to sql and their bound values to params, routing every column through sqlOperatorFor(com.ultikits.ultitools.entities.Comparison, java.lang.String) so the four relational builders cannot diverge in how they honor a Comparison again.

      When skipEmpty is true, conditions whose WhereCondition#isEmpty() is true are skipped — getAll(WhereCondition...)'s pre-existing behavior, preserved here rather than changed. exist(WhereCondition...), page(int, int, WhereCondition...), and del(WhereCondition...) do not skip empty conditions; that asymmetry already existed before this method and is not resolved by this change (see 02-03-SUMMARY.md).

      Parameters:
      sql - the SQL being built; must already hold the statement up to (not including) the WHERE clause
      params - the parameter list to append bound values to, in SQL order
      conditions - the conditions to render; must be non-null and non-empty
      skipEmpty - whether to skip conditions whose isEmpty() is true
    • appendConditions

      private void appendConditions(StringBuilder sql, List<Object> params, WhereCondition[] conditions, boolean skipEmpty, boolean nullMeansIsNull)
      appendConditions(StringBuilder, List, WhereCondition[], boolean), with the switch only updateIf(T, com.ultikits.ultitools.entities.WhereCondition...) turns on (#640): with nullMeansIsNull a condition with the default Comparison.EQUAL and a null value renders <column> IS NULL and binds nothing for it. Off -- the four read and delete builders -- rendering and binding are exactly as before (<column> = ? bound to null, which matches no row). The caller has already refused a null value under any other comparison.
      Parameters:
      sql - the SQL being built
      params - the parameter list to append bound values to, in SQL order
      conditions - the conditions to render; must be non-null and non-empty
      skipEmpty - whether to skip conditions whose isEmpty() is true
      nullMeansIsNull - whether an EQUAL condition with a null value means IS NULL
    • exist

      public boolean exist(T object)
      Description copied from interface: DataOperator
      Check if the data record exists.
      Specified by:
      exist in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      object - Data record entity
      Returns:
      Whether the record exists
    • exist

      public boolean exist(WhereCondition... whereConditions)
      Check if the data record exists.

      JDBC drivers report COUNT(*) as an Integer or a Long (sqlite-jdbc returns an Integer), so the count is read as a Number.

      Specified by:
      exist in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      whereConditions - Conditions
      Returns:
      Whether the record exists
    • getById

      public T getById(Object id)
      Description copied from interface: DataOperator
      Get data record by ID.
      Specified by:
      getById in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      id - Record ID
      Returns:
      Data record
    • getAll

      public List<T> getAll()
      Description copied from interface: DataOperator
      Get all data record.
      Specified by:
      getAll in interface DataOperator<T extends BaseDataEntity<String>>
      Returns:
      Data record list
    • getAll

      public List<T> getAll(WhereCondition... whereConditions)
      Description copied from interface: DataOperator
      Get all data record by conditions.
      Specified by:
      getAll in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      whereConditions - Conditions
      Returns:
      Data record list
    • getLike

      public List<T> getLike(String column, String value, DataOperator.LikeType likeType)
      Description copied from interface: DataOperator
      Fuzzy Query
      Specified by:
      getLike in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      column - Column name
      value - Query value
      likeType - Like type
      Returns:
      Data record list
    • page

      public List<T> page(int page, int size, WhereCondition... whereConditions)
      Description copied from interface: DataOperator
      Get data record by page.
      Specified by:
      page in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      page - Page number
      size - Page size
      whereConditions - Conditions
      Returns:
      Data record list
    • insert

      public void insert(T obj)
      Description copied from interface: DataOperator
      Insert data record.
      Specified by:
      insert in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      obj - Data record
    • del

      public void del(WhereCondition... whereConditions)
      Deletes by predicate, without loading matched rows into entities first. Consequently this overload does not fire onDelete() -- there is no entity to fire it on without fabricating one, and this class does not fabricate entities to satisfy a hook. delById(Object) fetches the entity first and does fire onDelete() on it (02-08-PLAN.md's delete-hook-path split).
      Specified by:
      del in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      whereConditions - Conditions
    • delById

      public void delById(Object id)
      Deletes by id, fetching the entity first so onDelete() can fire on it -- unlike del(WhereCondition...), which deletes by predicate without materializing rows and therefore does not fire the hook. If no row matches id, nothing is fetched and onDelete() does not fire.
      Specified by:
      delById in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      id - Record ID
    • deleteByIdCounted

      public int deleteByIdCounted(Object id)
      delById(Object), returning the affected-row count the database reported for the DELETE (#521), so Query#delete() can return rows removed instead of rows matched.
      Specified by:
      deleteByIdCounted in interface RowCountingDelete
      Parameters:
      id - the row id
      Returns:
      the number of rows the DELETE removed
    • update

      public void update(String column, Object value, Object id)
      Description copied from interface: DataOperator
      Update one field of one record.
      Specified by:
      update in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      column - Column name
      value - New value
      id - Record ID
    • update

      public void update(T obj) throws IllegalAccessException
      Description copied from interface: DataOperator
      Update data record by object. It will not update the fields that the incoming entity does not have.
      Specified by:
      update in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      obj - Data record
      Throws:
      IllegalAccessException - Please referIllegalAccessException
    • updateCounted

      public int updateCounted(T entity)
      Counted update: DataOperator.update(BaseDataEntity), returning how many rows it wrote, so the caller can tell that nothing was written (#558).

      An update by a non-null id that matches no row writes nothing on every backend: SQLite, MySQL and JSON each log one WARNING naming the table and the id, and return normally. update(T) gives the caller no way to see that; this method returns 0 instead of 1. Use it wherever "the row was gone" must be treated as a failure -- for example a write that credits an account that another server has just removed. For a write that should apply only while the stored row still has the values you read, use DataOperator.updateIf(T, com.ultikits.ultitools.entities.WhereCondition...) instead.

      The framework's operators return the backend's own affected-row count. This default, which a third-party implementation inherits unless it overrides it, checks with DataOperator.exist(WhereCondition...) whether a row with the entity's id exists, returns 0 without writing if it does not, and otherwise calls update(T) and returns 1. It cannot see what that update wrote, so one case remains in which it reports 1 although nothing was written: another writer deleting the row during that one call.

      Returns the database's affected-row count for the UPDATE.

      Specified by:
      updateCounted in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      entity - the new state of the row, carrying the id of the row to write
      Returns:
      the number of rows written: 1, or 0 when no row has that id
    • updateRow

      private int updateRow(T obj) throws IllegalAccessException
      update(BaseDataEntity), returning the number of rows the UPDATE changed. A non-null id that matches no row writes nothing and logs one WARNING (#558).
      Throws:
      IllegalAccessException
    • warnIfNoRow

      private int warnIfNoRow(int changed, Object id)
      Logs one WARNING when an update by a non-null id changed no row, and returns the count unchanged (#558, maintainer decision 2026-09-29). The call still returns normally: a row another writer deleted is not a storage failure, and module code catches exceptions from update as storage failures.
    • updateIf

      public boolean updateIf(T entity, WhereCondition... expected)
      Conditional update: writes entity over the stored row with the same id only if that row still matches every one of expected, and reports whether it did (#543).

      The check and the write are one step: on SQLite and MySQL a single UPDATE ... WHERE id = ? AND <expected> whose affected-row count decides the result, so it holds across servers sharing one database; on the JSON backend the check and the write run under the operator's own lock (a JSON store is local to one server). The conditions mean exactly what they mean in DataOperator.getAll(WhereCondition...) on the same backend, values are bound as parameters, and a condition whose isEmpty() is true is ignored.

      Typical use is compare-and-set: read a row, compute the new state, and write it conditioned on the value that was read; if another writer changed the row in between, nothing is written and false comes back, so the caller re-reads and decides again rather than writing on top. UltiEconomy's one-time wallet merge uses it this way, conditioned on the balance it read:

      
       Account read = accounts.getById(id);
       double seen = read.getBalance();
       read.setBalance(seen + amount);
       if (!accounts.updateIf(read, WhereCondition.builder().column("balance").value(seen).build())) {
           // someone else wrote first: re-read and decide again
       }
       
      Like DataOperator.update(BaseDataEntity), every mapped field of entity is written, and onUpdate() fires on entity before the fields are read, whether or not the write then applies.

      A null expected value means IS NULL under the default Comparison.EQUAL comparison: the condition holds only while the stored column is unset -- on SQLite and MySQL <column> IS NULL inside the same single statement, on JSON an entry whose field is absent or JSON null. A compare-and-set against a column that was unset when it was read therefore misses, and keeps the value, when another writer filled the column in between. This meaning belongs to updateIf alone: DataOperator.getAll(WhereCondition...), DataOperator.exist(WhereCondition...), DataOperator.del(WhereCondition...) and DataOperator.page(int, int, WhereCondition...) treat a null condition value as they always have. (6.3.0 snapshots published before this change refused a null expected value; no release did.)

      One UPDATE ... SET <every mapped column> WHERE id = ? AND <expected> statement; the database's affected-row count decides the result, so the check and the write cannot be separated by another writer, on the same server or another one sharing the database. Condition columns pass the same allow-list as every other WHERE clause here, and values are bound as parameters. A null expected value under the default Comparison.EQUAL renders <column> IS NULL and binds nothing (#640); this meaning belongs to this method alone -- getAll(WhereCondition...), exist(WhereCondition...), del(WhereCondition...) and page(int, int, WhereCondition...) are unchanged.

      Specified by:
      updateIf in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      entity - the new state of the row, carrying the id of the row to write
      expected - the conditions the stored row must still meet
      Returns:
      true if the row matched and was written; false if no row with that id matched every condition, in which case nothing was written
    • appendUpdateSet

      private void appendUpdateSet(StringBuilder sql, List<Object> params, T obj) throws IllegalAccessException
      Appends UPDATE <table> SET `col` = ?, ... for every @Column field of obj, and the values in the same order, serialised exactly as insert(T) does. Shared by update(BaseDataEntity) and updateIf(T, com.ultikits.ultitools.entities.WhereCondition...).
      Throws:
      IllegalAccessException
    • transaction

      public <R> R transaction(Callable<R> action) throws Exception
      Execute operations within a transaction. All operations commit together or roll back on exception.

      Every backend this framework ships (SQLite, MySQL, JSON) runs the action in a transaction: it joins the transaction already active on the calling thread, if any, and any Throwable the action throws rolls it back and is rethrown. SQLite and MySQL use a database transaction. On JSON, the action's writes are staged until commit and are invisible to other threads until then; the commit applies them together, so other threads see all of them at once; a rollback discards them.

      On JSON, a transaction this method begins locks each table at its first read or write and holds the lock until it commits or rolls back, so a read-modify-write inside the action cannot lose an update, as in 6.2.5, where each JSON transaction(...) callback ran alone per operator. A read inside the action can therefore wait for another transaction that holds the table: at most 5 seconds, then it fails with a DataAccessException and the transaction rolls back. A call that joins an active transaction keeps that transaction's isolation: inside a @Transactional method at the default isolation, JSON reads take no table lock.

      When the commit itself fails: on SQLite and MySQL, the exception does not tell whether the database stored the transaction (the outcome is unknown; see TransactionManager.commit()), and nothing is retried, so code that must not apply a write twice re-reads the data, or uses DataOperator.updateIf(T, com.ultikits.ultitools.entities.WhereCondition...), before trying again. On JSON a failed commit commits nothing. An UnexpectedRollbackException from the commit means the transaction was rolled back, none of its writes stored, on every backend.

      This default implementation, used only by a third-party operator that does not override it, runs the action directly without a transaction.

      Any Throwable the action throws -- an Error included (#634) -- rolls the transaction back and is rethrown as the same instance, so the thread's transaction context is gone afterwards and the next transaction(...) on that thread starts its own. When the rollback itself throws, the action's throwable still reaches the caller and the rollback failure is attached to it as suppressed. A commit that throws (a failed commit, or UnexpectedRollbackException for a transaction marked rollback-only) has already ended the transaction and is not followed by a rollback. A commit or rollback that fails never ends in an implicit commit: see DataSourceTransactionManager.

      Specified by:
      transaction in interface DataOperator<T extends BaseDataEntity<String>>
      Type Parameters:
      R - the return type
      Parameters:
      action - the operations to execute
      Returns:
      the result of the action
      Throws:
      Exception - if the action fails
    • transaction

      public void transaction(Runnable action)
      Description copied from interface: DataOperator
      Execute operations within a transaction (void variant), with the same guarantees as DataOperator.transaction(Callable) on every shipped backend, including the JSON backend's table lock at the first read of a transaction this method begins. The same commit outcomes apply: see DataOperator.transaction(Callable).

      This default implementation, used only by a third-party operator that does not override it, runs the action directly without a transaction.

      Specified by:
      transaction in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      action - the operations to execute
    • insertAll

      public void insertAll(List<T> entities)
      Description copied from interface: DataOperator
      Insert multiple entities atomically: on every shipped backend either all of them are stored or, if one fails, none is. Uses JDBC batch for SQL backends; on JSON the batch is one transaction. Inside an active transaction the batch joins it.
      Specified by:
      insertAll in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      entities - the entities to insert
    • updateAll

      public void updateAll(List<T> entities) throws IllegalAccessException
      Description copied from interface: DataOperator
      Update multiple entities atomically: on every shipped backend either all of them are updated or, if one fails, none is. Uses JDBC batch for SQL backends; on JSON the batch is one transaction. Inside an active transaction the batch joins it.

      This default implementation, used only by a third-party operator that does not override it, updates the entities one by one without a transaction.

      Specified by:
      updateAll in interface DataOperator<T extends BaseDataEntity<String>>
      Parameters:
      entities - the entities to update
      Throws:
      IllegalAccessException - if field access fails
    • persistedValue

      private Object persistedValue(Field field, String columnName, T obj) throws IllegalAccessException
      The value written for one @Column field of obj, serialised as every write path here always has: a non-basic value goes out as JSON, with a bare string's quotes removed.

      The id column is written from getId(), not from the inherited field, because getId() is what every WHERE id = ? binds (#546). An entity that overrides getId() onto another column -- UltiEssentials' and UltiKits' do -- never sets the inherited field, so writing the field stored a NULL id that no update or delete could reach (and that MySQL refused outright).

      Throws:
      IllegalAccessException
    • getColumnMappings

      private List<AbstractRelationalDataOperator.ColumnMapping> getColumnMappings()
    • createTableSqlFromClazz

      protected abstract String createTableSqlFromClazz(Class<T> type)
      Creates the SQL statement to create the table.

      Override this method to customize table creation for different databases.

      Parameters:
      type - the entity class
      Returns:
      the CREATE TABLE SQL statement
    • normaliseBoolean

      private static Object normaliseBoolean(Object value, String colName)
      Normalises whatever the driver returned for a boolean column into a real Boolean, so Gson deserialises it correctly.

      The previous version converted only Number, on the stated assumption that "SQLite stores BOOLEAN as INTEGER (0/1)". That assumption does not hold here, because of what this class writes: @Column's default type is VARCHAR(255) and buildColumnDefinitions(java.lang.Class<T>) emits it verbatim, so a boolean field with no explicit type gets a text column. rs.getObject then returns a String, the Number branch was skipped, and Gson coerced the JSON string "1" to false -- every defaulted boolean column read back false regardless of what was stored (#388). It was one root cause behind a banned player still being able to join, /unban reporting "not banned" for a player it had just banned, and a world's weather flag having no effect.

      Both representations are handled rather than one, because both exist in the field: tables created before this fix have text columns holding "1"/"0", and a column declared with an explicit numeric or boolean type returns a Number or Boolean. The column type is deliberately left as it is -- correcting it would change the schema for new installs only, would not help the tables that already exist, and reading both forms is required either way.

      An unrecognised value is logged rather than quietly becoming false. Quietly becoming false is precisely the behaviour this method exists to remove.

      Parameters:
      value - whatever ResultSet.getObject returned
      colName - the column name, for the diagnostic
      Returns:
      null, or a Boolean
      Since:
      6.3.0
    • buildColumnDefinitions

      protected String buildColumnDefinitions(Class<T> type)
      Builds the column definitions portion of CREATE TABLE.