Add Transaction.setGeneratedPropertiesEnabled(boolean) (#3799)

Adds transaction-level control over whether Ebean auto-generates values for @WhenCreated, @WhenModified, @WhoCreated and @WhoModified properties.

Motivation

Backup/restore scenarios need to preserve the original audit timestamps and user values when re-inserting exported data. Without this, every save overwrites those fields with the current time/user.

Usage

 try (Transaction txn = DB.beginTransaction()) {
   txn.setGeneratedPropertiesEnabled(false);
   bean.setWhenCreated(originalTimestamp);
   bean.setWhenModified(originalTimestamp);
   DB.save(bean);
   txn.commit();
 }

Behaviour

 - Disabled (false): generated property values are only written if the property currently has a null value. Any non-null value set on the bean is preserved.
 - @Version is unaffected: the version property always auto-increments regardless of this setting, preserving optimistic locking integrity.
 - Default is true: all existing behaviour is unchanged.

Files changed

 - Transaction — new setGeneratedPropertiesEnabled(boolean) with javadoc
 - SpiTransaction — new isGeneratedPropertiesEnabled()
 - SpiTransactionProxy — delegates both methods
 - JdbcTransaction — field + implementation (default true)
 - NoTransaction, ImplicitReadOnlyTransaction — no-op setter, true getter
 - PersistRequestBean — onInsertGeneratedProperties, onUpdateGeneratedProperties, onFailedUpdateUndoGeneratedProperties all gate on isGeneratedPropertiesEnabled()
 - TestGeneratedProperties — 3 new tests covering insert-preserves, insert-null-still-filled, and update-preserves

Supersedes

PR #2943 — same feature, renamed from setOverwriteGeneratedProperties to setGeneratedPropertiesEnabled for a clearer, positive-sense API.

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
This commit is contained in:
Rob Bygrave
2026-07-01 14:30:11 +12:00
committed by robin.bygrave
co-authored by robin.bygrave
parent 59793ad3c3
commit 3bb7fbecb1
8 changed files with 147 additions and 8 deletions
@@ -235,6 +235,27 @@ public interface Transaction extends AutoCloseable {
*/
void setUpdateAllLoadedProperties(boolean updateAllLoadedProperties);
/**
* Set to false to disable auto-generation of {@code @WhenCreated}, {@code @WhenModified},
* {@code @WhoCreated} and {@code @WhoModified} values for this transaction.
* <p>
* When disabled, Ebean will only set a generated property value if the property currently
* has a null value (for inserts) or is a {@code @Version} property. Any value already set
* on the bean is preserved.
* <p>
* This is useful in backup and restore scenarios where you need to retain the original
* audit timestamps and user values rather than have them overwritten.
* <pre>{@code
* try (Transaction txn = DB.beginTransaction()) {
* txn.setGeneratedPropertiesEnabled(false);
* bean.setWhenCreated(originalTimestamp);
* DB.save(bean);
* txn.commit();
* }
* }</pre>
*/
void setGeneratedPropertiesEnabled(boolean enable);
/**
* Set if the L2 cache should be skipped for "find by id" and "find by natural key" queries.
* <p>
@@ -103,6 +103,11 @@ public interface SpiTransaction extends Transaction {
*/
Boolean isUpdateAllLoadedProperties();
/**
* Return true if generated properties ({@code @WhenCreated} etc.) are enabled for this transaction.
*/
boolean isGeneratedPropertiesEnabled();
/**
* Return the batchSize specifically set for this transaction or 0.
* <p>
@@ -216,6 +216,16 @@ public abstract class SpiTransactionProxy implements SpiTransaction {
transaction.setUpdateAllLoadedProperties(updateAllLoaded);
}
@Override
public void setGeneratedPropertiesEnabled(boolean enable) {
transaction.setGeneratedPropertiesEnabled(enable);
}
@Override
public boolean isGeneratedPropertiesEnabled() {
return transaction.isGeneratedPropertiesEnabled();
}
@Override
public Boolean isUpdateAllLoadedProperties() {
return transaction.isUpdateAllLoadedProperties();
@@ -229,13 +229,13 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
GeneratedProperty generatedProperty = prop.generatedProperty();
if (prop.isVersion()) {
if (isLoadedProperty(prop)) {
// @Version property must be loaded to be involved
// @Version property must be loaded to be involved — always auto-incremented
Object value = generatedProperty.getUpdateValue(prop, entityBean, now());
Object oldVal = prop.getValue(entityBean);
setVersionValue(value);
intercept.setOldValue(prop.propertyIndex(), oldVal);
}
} else {
} else if (transaction == null || transaction.isGeneratedPropertiesEnabled()) {
// @WhenModified set without invoking interception
Object oldVal = prop.getValue(entityBean);
Object value = generatedProperty.getUpdateValue(prop, entityBean, now());
@@ -247,17 +247,22 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
private void onFailedUpdateUndoGeneratedProperties() {
for (BeanProperty prop : beanDescriptor.propertiesGenUpdate()) {
Object oldVal = intercept.origValue(prop.propertyIndex());
if (oldVal != null) {
prop.setValue(entityBean, oldVal);
if (prop.isVersion() || transaction == null || transaction.isGeneratedPropertiesEnabled()) {
// undo version always (it was always set); undo others only if they were set
Object oldVal = intercept.origValue(prop.propertyIndex());
if (oldVal != null) {
prop.setValue(entityBean, oldVal);
}
}
}
}
private void onInsertGeneratedProperties() {
for (BeanProperty prop : beanDescriptor.propertiesGenInsert()) {
Object value = prop.generatedProperty().getInsertValue(prop, entityBean, now());
prop.setValueChanged(entityBean, value);
if (prop.isVersion() || transaction == null || transaction.isGeneratedPropertiesEnabled() || prop.getValue(entityBean) == null) {
Object value = prop.generatedProperty().getInsertValue(prop, entityBean, now());
prop.setValueChanged(entityBean, value);
}
}
}
@@ -255,6 +255,15 @@ final class ImplicitReadOnlyTransaction implements SpiTransaction, TxnProfileEve
return null;
}
@Override
public void setGeneratedPropertiesEnabled(boolean enable) {
}
@Override
public boolean isGeneratedPropertiesEnabled() {
return true;
}
@Override
public void setBatchMode(boolean batchMode) {
}
@@ -52,6 +52,7 @@ class JdbcTransaction implements SpiTransaction, TxnProfileEventCodes {
private boolean queryOnly = true;
private boolean localReadOnly;
private Boolean updateAllLoadedProperties;
private boolean generatedPropertiesEnabled = true;
private boolean oldBatchMode;
private boolean batchMode;
private boolean batchOnCascadeMode;
@@ -410,6 +411,16 @@ class JdbcTransaction implements SpiTransaction, TxnProfileEventCodes {
return updateAllLoadedProperties;
}
@Override
public final void setGeneratedPropertiesEnabled(boolean enable) {
this.generatedPropertiesEnabled = enable;
}
@Override
public final boolean isGeneratedPropertiesEnabled() {
return generatedPropertiesEnabled;
}
@Override
public final void setBatchMode(boolean batchMode) {
this.batchMode = batchMode;
@@ -210,6 +210,15 @@ final class NoTransaction implements SpiTransaction {
public void setUpdateAllLoadedProperties(boolean updateAllLoadedProperties) {
}
@Override
public void setGeneratedPropertiesEnabled(boolean enable) {
}
@Override
public boolean isGeneratedPropertiesEnabled() {
return true;
}
@Override
public void setSkipCache(boolean skipCache) {
}
@@ -1,10 +1,13 @@
package org.tests.generated;
import io.ebean.xtest.BaseTestCase;
import io.ebean.DB;
import io.ebean.Transaction;
import io.ebean.xtest.BaseTestCase;
import org.junit.jupiter.api.Test;
import org.tests.model.EGenProps;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -47,4 +50,70 @@ public class TestGeneratedProperties extends BaseTestCase {
DB.delete(bean);
}
@Test
public void test_insert_generatedPropertiesDisabled_preservesBeanValues() {
Instant created = Instant.parse("2022-01-01T00:00:00Z");
Instant updated = Instant.parse("2022-01-02T00:00:00Z");
EGenProps bean = new EGenProps();
bean.setName("restore-insert");
bean.setInstantCreated(created);
bean.setInstantUpdated(updated);
try (Transaction txn = DB.beginTransaction()) {
txn.setGeneratedPropertiesEnabled(false);
DB.save(bean);
txn.commit();
}
bean = DB.find(EGenProps.class, bean.getId());
assertThat(bean.getInstantCreated()).isEqualTo(created);
assertThat(bean.getInstantUpdated()).isEqualTo(updated);
DB.delete(bean);
}
@Test
public void test_insert_generatedPropertiesDisabled_nullValueStillFilled() {
EGenProps bean = new EGenProps();
bean.setName("restore-insert-null");
// intentionally NOT setting instantCreated / instantUpdated
try (Transaction txn = DB.beginTransaction()) {
txn.setGeneratedPropertiesEnabled(false);
DB.save(bean);
txn.commit();
}
bean = DB.find(EGenProps.class, bean.getId());
// null values must still be filled by the generator
assertThat(bean.getInstantCreated()).isNotNull();
assertThat(bean.getInstantUpdated()).isNotNull();
DB.delete(bean);
}
@Test
public void test_update_generatedPropertiesDisabled_preservesBeanValues() {
EGenProps bean = new EGenProps();
bean.setName("restore-update");
DB.save(bean);
Instant created = Instant.parse("2022-03-01T00:00:00Z");
Instant updated = Instant.parse("2022-03-02T00:00:00Z");
bean = DB.find(EGenProps.class, bean.getId());
bean.setInstantCreated(created);
bean.setInstantUpdated(updated);
try (Transaction txn = DB.beginTransaction()) {
txn.setGeneratedPropertiesEnabled(false);
DB.save(bean);
txn.commit();
}
bean = DB.find(EGenProps.class, bean.getId());
assertThat(bean.getInstantCreated()).isEqualTo(created);
assertThat(bean.getInstantUpdated()).isEqualTo(updated);
DB.delete(bean);
}
}