Class AbstractRelationalDataOperator<T extends BaseDataEntity<String>>
- Type Parameters:
T- the entity type
- All Implemented Interfaces:
DataOperator<T>,RowCountingDelete
- Direct Known Subclasses:
MysqlDataOperator,SQLiteDataOperator
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
-
Nested Class Summary
Nested ClassesModifier and TypeClassDescriptionprivate static classprivate static interfaceMaterialises the current row of a result set as an entity; seegetRowMapper().Nested classes/interfaces inherited from interface com.ultikits.ultitools.interfaces.DataOperator
DataOperator.LikeType -
Field Summary
FieldsModifier and TypeFieldDescriptionprivate static final StringAlias of the row-identifier columnbackfillNullIds(java.lang.String)selects next to the entity's own.The Java type of the field behind each@Columnname, reflected once at construction in the same pass asknownColumns(which is its key set).protected final DataSourceprivate static final com.google.gson.GsonDefault Gson has no bundled adapter forjava.time.LocalDateTime: its reflective fallback tries to reachLocalDateTime's private fields, which JDK 9+'s module system refuses without--add-opens java.base/java.time, throwingJsonIOExceptionat the first non-null@Column-mappedLocalDateTimevalue (02-08 --AuditableDataEntity#createdAt/updatedAtwere alwaysnullbefore this plan, so the per-fieldinsert(T)/update(java.lang.String, java.lang.Object, java.lang.Object)JSON fallback below never reached this type until the lifecycle hooks started populating it).private static final StringThe primary-key column every generated table declares; its value is alwaysgetId().The entity's own@Columnnames, reflected once at construction.private static final charThe escape character of theLIKEthat INCLUDE, STARTSWITH and ENDSWITH render; seesqlOperatorFor(com.ultikits.ultitools.entities.Comparison, java.lang.String)for why it is not a backslash.private static final Loggerprotected final org.apache.commons.dbutils.QueryRunnerprotected final Stringprotected TransactionManager -
Constructor Summary
ConstructorsModifierConstructorDescriptionprotectedAbstractRelationalDataOperator(DataSource dataSource, Class<T> type) Creates a new data operator. -
Method Summary
Modifier and TypeMethodDescriptionprivate voidappendConditions(StringBuilder sql, List<Object> params, WhereCondition[] conditions, boolean skipEmpty) Appends aWHEREclause built fromconditionstosqland their bound values toparams, routing every column throughsqlOperatorFor(com.ultikits.ultitools.entities.Comparison, java.lang.String)so the four relational builders cannot diverge in how they honor aComparisonagain.private voidappendConditions(StringBuilder sql, List<Object> params, WhereCondition[] conditions, boolean skipEmpty, boolean nullMeansIsNull) appendConditions(StringBuilder, List, WhereCondition[], boolean), with the switch onlyupdateIf(T, com.ultikits.ultitools.entities.WhereCondition...)turns on (#640): withnullMeansIsNulla condition with the defaultComparison.EQUALand anullvalue renders<column> IS NULLand binds nothing for it.private voidappendUpdateSet(StringBuilder sql, List<Object> params, T obj) AppendsUPDATE <table> SET `col` = ?, ...for every@Columnfield ofobj, and the values in the same order, serialised exactly asinsert(T)does.private voidapplyStatementTimeout(PreparedStatement statement) Applies the same per-statement query timeoutqueryRunner(aTimeoutAwareQueryRunner) would, to aPreparedStatementbuilt directly againstdataSource-- the two call sites ininsertAll(java.util.List<T>)andupdateAll(java.util.List<T>)that bypassQueryRunnerentirely.private booleanassignId(Connection conn, String update, String id, Object rowId) Writesidinto the row with this row identifier, on the backfill's own connection (so inside its transaction), if the row still has none.protected final intbackfillNullIds(String rowIdColumn) Gives every row of this operator's table whoseidisNULLan 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).protected StringbuildColumnDefinitions(Class<T> type) Builds the column definitions portion of CREATE TABLE.buildColumnTypes(Class<?> entityType) Reflects the entity's@Columnmappings once: their names arevalidateColumn(String)'s allow-list, and the field type behind each name is whatorderedComparisonOperand(String, Class)receives.protected abstract StringcreateTableSqlFromClazz(Class<T> type) Creates the SQL statement to create the table.private LongSuppliesqueryRunner's per-statement query timeout deadline (D-10): the active transaction's remaining-time budget, ornullwhen none is configured (no active transaction, or@Transactional(timeout=0), the default).voiddel(WhereCondition... whereConditions) Deletes by predicate, without loading matched rows into entities first.voidDeletes by id, fetching the entity first soonDelete()can fire on it -- unlikedel(WhereCondition...), which deletes by predicate without materializing rows and therefore does not fire the hook.intdelById(Object), returning the affected-row count the database reported for theDELETE(#521), soQuery#delete()can return rows removed instead of rows matched.booleanexist(WhereCondition... whereConditions) Check if the data record exists.booleanCheck if the data record exists.private TfetchRawById(Object id) Fetches a single row by id without firingonLoad().getAll()Get all data record.getAll(WhereCondition... whereConditions) Get all data record by conditions.Get data record by ID.getLike(String column, String value, DataOperator.LikeType likeType) Fuzzy QueryThe read handler used bygetAll(WhereCondition...),getLike(java.lang.String, java.lang.String, com.ultikits.ultitools.interfaces.DataOperator.LikeType), andpage(int, int, com.ultikits.ultitools.entities.WhereCondition...): firesonLoad()once per row it materializes.The same row-materialization logic asgetListHandler(), without firingonLoad().Materialises one row as an entity without firingonLoad(): shared bygetRawListHandler()andbackfillNullIds(java.lang.String), so a row is read the same way whichever needs it.private voidInitializes the database table.voidInsert data record.voidInsert multiple entities atomically: on every shipped backend either all of them are stored or, if one fails, none is.private static booleanprivate booleanisHeld(Connection conn, String held, String id) Whether a row of this table already holdsidin its id column.private ObjectlikeWrappedValue(WhereCondition condition) Builds the parameter to bind for a condition's value.private static ObjectnormaliseBoolean(Object value, String colName) Normalises whatever the driver returned for abooleancolumn into a realBoolean, so Gson deserialises it correctly.protected StringorderedComparisonOperand(String column, Class<?> fieldType) Returns the SQL operand that aGREATERorLESScondition compares on the column side.protected StringorderedComparisonPlaceholder(Class<?> fieldType, Object value) Returns the SQL operand that aGREATERorLESScondition compares on the parameter side: the placeholder?the condition's value is bound to, or an expression around it.page(int page, int size, WhereCondition... whereConditions) Get data record by page.private ObjectpersistedValue(Field field, String columnName, T obj) The value written for one@Columnfield ofobj, serialised as every write path here always has: a non-basic value goes out as JSON, with a bare string's quotes removed.private TreadEntity(AbstractRelationalDataOperator.RowMapper<T> mapper, ResultSet rs) private voidreadRowsWithoutId(Connection conn, String select, List<Object> rowIds, List<String> candidates, List<Boolean> fromEntity) Reads every row ofselect(row identifier first, then the table's columns) and decides which id, if any, would make it addressable.private booleanWhetherentity, read back withcandidatein itsidcolumn, reportscandidatefromgetId()-- that is, whether writing it makes the row addressable.private voidRefuses an update or delete addressed by anullid (#546):WHERE id = ?bound tonullmatches no row, so the call used to return normally having changed nothing.voidsetTransactionManager(TransactionManager transactionManager) Sets the transaction manager for transaction-aware operations.private StringsqlOperatorFor(Comparison comparison, String orderedPlaceholder) Maps aComparisonto its SQL operator fragment (including the placeholder).voidtransaction(Runnable action) Execute operations within a transaction (void variant), with the same guarantees asDataOperator.transaction(Callable)on every shipped backend, including the JSON backend's table lock at the first read of a transaction this method begins.<R> Rtransaction(Callable<R> action) Execute operations within a transaction.voidUpdate one field of one record.voidUpdate data record by object.voidUpdate multiple entities atomically: on every shipped backend either all of them are updated or, if one fails, none is.intupdateCounted(T entity) Counted update:DataOperator.update(BaseDataEntity), returning how many rows it wrote, so the caller can tell that nothing was written (#558).booleanupdateIf(T entity, WhereCondition... expected) Conditional update: writesentityover the stored row with the same id only if that row still matches every one ofexpected, and reports whether it did (#543).private intupdate(BaseDataEntity), returning the number of rows theUPDATEchanged.private voidvalidateColumn(String column) Refuses a column identifier that is not among the entity's own reflected@Columnmappings, before it can be concatenated into SQL text (T-02-SQLI-1).private intwarnIfNoRow(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).Methods inherited from class java.lang.Object
clone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, waitMethods inherited from interface com.ultikits.ultitools.interfaces.DataOperator
query
-
Field Details
-
LOGGER
-
ID_COLUMN
The primary-key column every generated table declares; its value is alwaysgetId().- See Also:
-
BACKFILL_ROWID
Alias of the row-identifier columnbackfillNullIds(java.lang.String)selects next to the entity's own.- See Also:
-
LIKE_ESCAPE
private static final char LIKE_ESCAPEThe escape character of theLIKEthat INCLUDE, STARTSWITH and ENDSWITH render; seesqlOperatorFor(com.ultikits.ultitools.entities.Comparison, java.lang.String)for why it is not a backslash.- See Also:
-
GSON
private static final com.google.gson.Gson GSONDefault Gson has no bundled adapter forjava.time.LocalDateTime: its reflective fallback tries to reachLocalDateTime's private fields, which JDK 9+'s module system refuses without--add-opens java.base/java.time, throwingJsonIOExceptionat the first non-null@Column-mappedLocalDateTimevalue (02-08 --AuditableDataEntity#createdAt/updatedAtwere alwaysnullbefore this plan, so the per-fieldinsert(T)/update(java.lang.String, java.lang.Object, java.lang.Object)JSON fallback below never reached this type until the lifecycle hooks started populating it). Serializes to/fromLocalDateTime.toString()'s ISO-8601 form, whichLocalDateTime.parse(CharSequence)reads back exactly. -
type
-
dataSource
-
tableName
-
queryRunner
protected final org.apache.commons.dbutils.QueryRunner queryRunner -
transactionManager
-
knownColumns
The entity's own@Columnnames, reflected once at construction. The allow-listvalidateColumn(String)checks every WHERE-clause column identifier against before it is concatenated into SQL text (T-02-SQLI-1). -
columnTypes
The Java type of the field behind each@Columnname, reflected once at construction in the same pass asknownColumns(which is its key set).appendConditions(java.lang.StringBuilder, java.util.List<java.lang.Object>, com.ultikits.ultitools.entities.WhereCondition[], boolean)passes it toorderedComparisonOperand(String, Class)forGREATERandLESS.
-
-
Constructor Details
-
AbstractRelationalDataOperator
Creates a new data operator.- Parameters:
dataSource- the data source to usetype- the entity class
-
-
Method Details
-
buildColumnTypes
Reflects the entity's@Columnmappings once: their names arevalidateColumn(String)'s allow-list, and the field type behind each name is whatorderedComparisonOperand(String, Class)receives. This mirrors the same reflected metadatabuildColumnDefinitions(Class)andgetColumnMappings()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
@ColumnSQL name declared onentityType(including inherited fields, e.g.BaseDataEntity'sid) to the Java type of its field
-
validateColumn
Refuses a column identifier that is not among the entity's own reflected@Columnmappings, before it can be concatenated into SQL text (T-02-SQLI-1). Called fromappendConditions(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-suppliedWhereCondition- Throws:
DataAccessException- ifcolumnis not a known column oftype
-
setTransactionManager
Sets the transaction manager for transaction-aware operations.- Parameters:
transactionManager- the transaction manager
-
currentTimeoutDeadlineNanos
SuppliesqueryRunner's per-statement query timeout deadline (D-10): the active transaction's remaining-time budget, ornullwhen none is configured (no active transaction, or@Transactional(timeout=0), the default). Also used directly byapplyStatementTimeout(java.sql.PreparedStatement)for the twoPreparedStatementcall sites that bypassqueryRunnerentirely. A method reference rather than a field capture, so every call readstransactionManager's *current* value -- this operator outlives any single transaction, andsetTransactionManager(com.ultikits.ultitools.interfaces.TransactionManager)can swap the manager out.- Returns:
- the active transaction's timeout deadline, or
nullif none is configured
-
applyStatementTimeout
Applies the same per-statement query timeoutqueryRunner(aTimeoutAwareQueryRunner) would, to aPreparedStatementbuilt directly againstdataSource-- the two call sites ininsertAll(java.util.List<T>)andupdateAll(java.util.List<T>)that bypassQueryRunnerentirely. A timeout wired only intoqueryRunner'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
Gives every row of this operator's table whoseidisNULLan 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 noNOT NULL) accepted theNULL, 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, butWHERE id = ?bound tonullmatches 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 theidcolumn reports it, since every lookup bindsgetId(). 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 isnullor a row that cannot be read as the entity. Only theidcolumn is written, guarded byid IS NULLand 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 atSEVERE; 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
NULLid (SQLiteDataOperator); MySQL rejects aNULLprimary 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 ofselect(row identifier first, then the table's columns) and decides which id, if any, would make it addressable.Every
WHERE id = ?bindsgetId(), and an entity may derivegetId()from another column -- UltiEssentials' and UltiKits' do -- so an id is useful only if an entity read back with it in theidcolumn 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 anullid), and a row that cannot be read as the entity at all, getnull: 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
Whetherentity, read back withcandidatein itsidcolumn, reportscandidatefromgetId()-- that is, whether writing it makes the row addressable. -
isHeld
Whether a row of this table already holdsidin its id column.- Throws:
SQLException
-
assignId
private boolean assignId(Connection conn, String update, String id, Object rowId) throws SQLException Writesidinto 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 asfalseand 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
-
requireId
Refuses an update or delete addressed by anullid (#546):WHERE id = ?bound tonullmatches no row, so the call used to return normally having changed nothing.- Parameters:
id- the id the caller addressed the row byoperation- what was attempted, for the message- Throws:
DataAccessException- ifidisnull
-
getListHandler
The read handler used bygetAll(WhereCondition...),getLike(java.lang.String, java.lang.String, com.ultikits.ultitools.interfaces.DataOperator.LikeType), andpage(int, int, com.ultikits.ultitools.entities.WhereCondition...): firesonLoad()once per row it materializes.getById(java.lang.Object)anddelById(java.lang.Object)deliberately do *not* go through this handler -- seegetRawListHandler(). -
getRawListHandler
The same row-materialization logic asgetListHandler(), without firingonLoad(). Used internally bygetById(java.lang.Object)(which firesonLoad()itself, exactly once, on the single entity it returns) anddelById(java.lang.Object)(which needs the entity to fireonDelete()on, but deleting is not loading -- see 02-08-PLAN.md's delete-hook-path split). -
getRowMapper
Materialises one row as an entity without firingonLoad(): shared bygetRawListHandler()andbackfillNullIds(java.lang.String), so a row is read the same way whichever needs it. A column labelledBACKFILL_ROWIDis skipped. -
fetchRawById
Fetches a single row by id without firingonLoad(). Shared bygetById(java.lang.Object)(which firesonLoad()on the result) anddelById(java.lang.Object)(which firesonDelete()instead -- deleting is not loading). -
sqlOperatorFor
Maps aComparisonto 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 givenComparisonmeans the same thing on every one of them, and onSimpleJsonDataOperator's JSON backend. There is no silent fallback to equality: an unhandled constant throws rather than being misread asEQUAL, since a silent default is exactly how this defect stayed invisible before.INCLUDE,STARTSWITHandENDSWITHrenderLIKE ? ESCAPE '!': the value is matched literally as text (seelikeWrappedValue(com.ultikits.ultitools.entities.WhereCondition)), the waySimpleJsonDataOperatorusescontains,startsWithandendsWith. The escape character is the exclamation mark (LIKE_ESCAPE), not a backslash: MySQL's defaultsql_modeneeds a doubled backslash inside theESCAPEliteral, and withNO_BACKSLASH_ESCAPESthat 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)andQuery#likeare separate pattern operators and are not affected;EQUAL,GREATERandLESSare unchanged here (the column side ofGREATERandLESSgoes throughorderedComparisonOperand(java.lang.String, java.lang.Class<?>), and their parameter side isorderedPlaceholder, whichappendConditions(java.lang.StringBuilder, java.util.List<java.lang.Object>, com.ultikits.ultitools.entities.WhereCondition[], boolean)takes fromorderedComparisonPlaceholder(java.lang.Class<?>, java.lang.Object)).- Parameters:
comparison- the comparison operatororderedPlaceholder- the parameter operand ofGREATERandLESS; the other comparisons always use a plain?- Returns:
- the SQL fragment, e.g.
" > ?"
-
likeWrappedValue
Builds the parameter to bind for a condition's value. ForINCLUDE,STARTSWITHandENDSWITHthe value is matched literally as text: it is rendered withString.valueOf(Object)(so anullvalue 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 placesSimpleJsonDataOperatormatches:INCLUDEon both sides,STARTSWITHafter the value,ENDSWITHbefore it. A backslash needs no escaping becausesqlOperatorFor(com.ultikits.ultitools.entities.Comparison, java.lang.String)names'!'as the only escape character.EQUAL,GREATERandLESSpass 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
Returns the SQL operand that aGREATERorLESScondition compares on the column side. The default returnscolumnunchanged, 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 defaultVARCHAR(255)column.appendConditions(java.lang.StringBuilder, java.util.List<java.lang.Object>, com.ultikits.ultitools.entities.WhereCondition[], boolean)calls it only forGREATERandLESS, and only aftervalidateColumn(String)has acceptedcolumn;EQUALand the threeLIKEcomparisons never reach it. Its result is concatenated into the SQL text, so an override must build it only fromcolumnand constants, never from a condition's value or any other caller text.- Parameters:
column- an allow-listed@Columnname of the entityfieldType- the Java type of the field mapped tocolumn- Returns:
- the SQL operand to compare, e.g.
columnor a cast of it - Since:
- 6.3.0
-
orderedComparisonPlaceholder
Returns the SQL operand that aGREATERorLESScondition 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 withorderedComparisonOperand(String, Class), when both sides must pass through the same conversion to compare the way the stored value reads back, for example afloatfield 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 whosedoublevalue is finite.appendConditions(java.lang.StringBuilder, java.util.List<java.lang.Object>, com.ultikits.ultitools.entities.WhereCondition[], boolean)calls it only forGREATERandLESS, aftervalidateColumn(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 comparesvalue- the condition's value, which is bound to the placeholder; may benull- 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 aWHEREclause built fromconditionstosqland their bound values toparams, routing every column throughsqlOperatorFor(com.ultikits.ultitools.entities.Comparison, java.lang.String)so the four relational builders cannot diverge in how they honor aComparisonagain.When
skipEmptyistrue, conditions whoseWhereCondition#isEmpty()istrueare skipped —getAll(WhereCondition...)'s pre-existing behavior, preserved here rather than changed.exist(WhereCondition...),page(int, int, WhereCondition...), anddel(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 clauseparams- the parameter list to append bound values to, in SQL orderconditions- the conditions to render; must be non-null and non-emptyskipEmpty- whether to skip conditions whoseisEmpty()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 onlyupdateIf(T, com.ultikits.ultitools.entities.WhereCondition...)turns on (#640): withnullMeansIsNulla condition with the defaultComparison.EQUALand anullvalue renders<column> IS NULLand binds nothing for it. Off -- the four read and delete builders -- rendering and binding are exactly as before (<column> = ?bound tonull, which matches no row). The caller has already refused anullvalue under any other comparison.- Parameters:
sql- the SQL being builtparams- the parameter list to append bound values to, in SQL orderconditions- the conditions to render; must be non-null and non-emptyskipEmpty- whether to skip conditions whoseisEmpty()is truenullMeansIsNull- whether an EQUAL condition with anullvalue meansIS NULL
-
exist
Description copied from interface:DataOperatorCheck if the data record exists.- Specified by:
existin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
object- Data record entity- Returns:
- Whether the record exists
-
exist
Check if the data record exists.JDBC drivers report
COUNT(*)as anIntegeror aLong(sqlite-jdbc returns anInteger), so the count is read as a Number.- Specified by:
existin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
whereConditions- Conditions- Returns:
- Whether the record exists
-
getById
Description copied from interface:DataOperatorGet data record by ID.- Specified by:
getByIdin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
id- Record ID- Returns:
- Data record
-
getAll
Description copied from interface:DataOperatorGet all data record.- Specified by:
getAllin interfaceDataOperator<T extends BaseDataEntity<String>>- Returns:
- Data record list
-
getAll
Description copied from interface:DataOperatorGet all data record by conditions.- Specified by:
getAllin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
whereConditions- Conditions- Returns:
- Data record list
-
getLike
Description copied from interface:DataOperatorFuzzy Query- Specified by:
getLikein interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
column- Column namevalue- Query valuelikeType- Like type- Returns:
- Data record list
-
page
Description copied from interface:DataOperatorGet data record by page.- Specified by:
pagein interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
page- Page numbersize- Page sizewhereConditions- Conditions- Returns:
- Data record list
-
insert
Description copied from interface:DataOperatorInsert data record.- Specified by:
insertin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
obj- Data record
-
del
Deletes by predicate, without loading matched rows into entities first. Consequently this overload does not fireonDelete()-- 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 fireonDelete()on it (02-08-PLAN.md's delete-hook-path split).- Specified by:
delin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
whereConditions- Conditions
-
delById
Deletes by id, fetching the entity first soonDelete()can fire on it -- unlikedel(WhereCondition...), which deletes by predicate without materializing rows and therefore does not fire the hook. If no row matchesid, nothing is fetched andonDelete()does not fire.- Specified by:
delByIdin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
id- Record ID
-
deleteByIdCounted
delById(Object), returning the affected-row count the database reported for theDELETE(#521), soQuery#delete()can return rows removed instead of rows matched.- Specified by:
deleteByIdCountedin interfaceRowCountingDelete- Parameters:
id- the row id- Returns:
- the number of rows the
DELETEremoved
-
update
Description copied from interface:DataOperatorUpdate one field of one record.- Specified by:
updatein interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
column- Column namevalue- New valueid- Record ID
-
update
Description copied from interface:DataOperatorUpdate data record by object. It will not update the fields that the incoming entity does not have.- Specified by:
updatein interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
obj- Data record- Throws:
IllegalAccessException- Please referIllegalAccessException
-
updateCounted
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 returns0instead of1. 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, useDataOperator.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, returns0without writing if it does not, and otherwise callsupdate(T)and returns1. It cannot see what thatupdatewrote, so one case remains in which it reports1although nothing was written: another writer deleting the row during that one call.Returns the database's affected-row count for the
UPDATE.- Specified by:
updateCountedin interfaceDataOperator<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, or0when no row has that id
-
updateRow
update(BaseDataEntity), returning the number of rows theUPDATEchanged. A non-null id that matches no row writes nothing and logs one WARNING (#558).- Throws:
IllegalAccessException
-
warnIfNoRow
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 fromupdateas storage failures. -
updateIf
Conditional update: writesentityover the stored row with the same id only if that row still matches every one ofexpected, 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 inDataOperator.getAll(WhereCondition...)on the same backend, values are bound as parameters, and a condition whoseisEmpty()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
falsecomes 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:
LikeAccount 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 }DataOperator.update(BaseDataEntity), every mapped field ofentityis written, andonUpdate()fires onentitybefore the fields are read, whether or not the write then applies.A
nullexpected value meansIS NULLunder the defaultComparison.EQUALcomparison: the condition holds only while the stored column is unset -- on SQLite and MySQL<column> IS NULLinside 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 toupdateIfalone:DataOperator.getAll(WhereCondition...),DataOperator.exist(WhereCondition...),DataOperator.del(WhereCondition...)andDataOperator.page(int, int, WhereCondition...)treat anullcondition value as they always have. (6.3.0 snapshots published before this change refused anullexpected 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. Anullexpected value under the defaultComparison.EQUALrenders<column> IS NULLand binds nothing (#640); this meaning belongs to this method alone --getAll(WhereCondition...),exist(WhereCondition...),del(WhereCondition...)andpage(int, int, WhereCondition...)are unchanged.- Specified by:
updateIfin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
entity- the new state of the row, carrying the id of the row to writeexpected- the conditions the stored row must still meet- Returns:
trueif the row matched and was written;falseif 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 AppendsUPDATE <table> SET `col` = ?, ...for every@Columnfield ofobj, and the values in the same order, serialised exactly asinsert(T)does. Shared byupdate(BaseDataEntity)andupdateIf(T, com.ultikits.ultitools.entities.WhereCondition...).- Throws:
IllegalAccessException
-
transaction
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
Throwablethe 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 aDataAccessExceptionand the transaction rolls back. A call that joins an active transaction keeps that transaction's isolation: inside a@Transactionalmethod 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 usesDataOperator.updateIf(T, com.ultikits.ultitools.entities.WhereCondition...), before trying again. On JSON a failed commit commits nothing. AnUnexpectedRollbackExceptionfrom 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
Throwablethe action throws -- anErrorincluded (#634) -- rolls the transaction back and is rethrown as the same instance, so the thread's transaction context is gone afterwards and the nexttransaction(...)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, orUnexpectedRollbackExceptionfor 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: seeDataSourceTransactionManager.- Specified by:
transactionin interfaceDataOperator<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
Description copied from interface:DataOperatorExecute operations within a transaction (void variant), with the same guarantees asDataOperator.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: seeDataOperator.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:
transactionin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
action- the operations to execute
-
insertAll
Description copied from interface:DataOperatorInsert 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:
insertAllin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
entities- the entities to insert
-
updateAll
Description copied from interface:DataOperatorUpdate 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:
updateAllin interfaceDataOperator<T extends BaseDataEntity<String>>- Parameters:
entities- the entities to update- Throws:
IllegalAccessException- if field access fails
-
persistedValue
The value written for one@Columnfield ofobj, serialised as every write path here always has: a non-basic value goes out as JSON, with a bare string's quotes removed.The
idcolumn is written fromgetId(), not from the inherited field, becausegetId()is what everyWHERE id = ?binds (#546). An entity that overridesgetId()onto another column -- UltiEssentials' and UltiKits' do -- never sets the inherited field, so writing the field stored aNULLid that no update or delete could reach (and that MySQL refused outright).- Throws:
IllegalAccessException
-
getColumnMappings
-
createTableSqlFromClazz
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
Normalises whatever the driver returned for abooleancolumn into a realBoolean, 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 isVARCHAR(255)andbuildColumnDefinitions(java.lang.Class<T>)emits it verbatim, so abooleanfield with no explicittypegets a text column.rs.getObjectthen returns aString, theNumberbranch was skipped, and Gson coerced the JSON string"1"tofalse-- 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,/unbanreporting "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 booleantypereturns aNumberorBoolean. 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 becomingfalseis precisely the behaviour this method exists to remove.- Parameters:
value- whateverResultSet.getObjectreturnedcolName- the column name, for the diagnostic- Returns:
null, or aBoolean- Since:
- 6.3.0
-
buildColumnDefinitions
Builds the column definitions portion of CREATE TABLE.
-