diff --git a/ebean-api/src/main/java/io/ebean/DInsertOptionsBuilder.java b/ebean-api/src/main/java/io/ebean/DInsertOptionsBuilder.java new file mode 100644 index 0000000000..ffec19f91f --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/DInsertOptionsBuilder.java @@ -0,0 +1,104 @@ +package io.ebean; + +final class DInsertOptionsBuilder implements InsertOptions.Builder { + + private Boolean getGeneratedKeys; + private boolean onConflictUpdate; + private boolean onConflictNothing; + private String constraint; + private String uniqueColumns; + private String updateSet; + + @Override + public InsertOptions.Builder onConflictNothing() { + this.onConflictNothing = true; + return this; + } + + @Override + public InsertOptions.Builder onConflictUpdate() { + this.onConflictUpdate = true; + return this; + } + + @Override + public InsertOptions.Builder constraint(String constraint) { + this.constraint = constraint; + return this; + } + + @Override + public InsertOptions.Builder uniqueColumns(String uniqueColumns) { + this.uniqueColumns = uniqueColumns; + return this; + } + + @Override + public InsertOptions.Builder updateSet(String updateSet) { + this.updateSet = updateSet; + return this; + } + + @Override + public InsertOptions.Builder getGeneratedKeys(boolean getGeneratedKeys) { + this.getGeneratedKeys = getGeneratedKeys; + return this; + } + + @Override + public InsertOptions build() { + return new Options(constraint, uniqueColumns, updateSet, onConflictUpdate, onConflictNothing, getGeneratedKeys); + } + + static final class Options implements InsertOptions { + + private static final String UPDATE = "U"; + private static final String NOTHING = "N"; + private static final String NORMAL = "_"; + private final String key; + private final Boolean getGeneratedKeys; + private final String constraint; + private final String uniqueColumns; + private final String updateSet; + + Options(String constraint, String uniqueColumns, String updateSet, boolean onConflictUpdate, boolean onConflictNothing, Boolean getGeneratedKeys) { + this.constraint = constraint; + this.uniqueColumns = uniqueColumns; + this.updateSet = updateSet; + this.getGeneratedKeys = getGeneratedKeys; + this.key = (onConflictUpdate ? UPDATE : onConflictNothing ? NOTHING : NORMAL) + + '+' + plus(constraint) + + '+' + plus(uniqueColumns) + + '+' + plus(updateSet); + } + + private String plus(String val) { + return val == null ? "" : val; + } + + @Override + public String key() { + return key; + } + + @Override + public String constraint() { + return constraint; + } + + @Override + public String uniqueColumns() { + return uniqueColumns; + } + + @Override + public String updateSet() { + return updateSet; + } + + @Override + public Boolean getGetGeneratedKeys() { + return getGeneratedKeys; + } + } +} diff --git a/ebean-api/src/main/java/io/ebean/Database.java b/ebean-api/src/main/java/io/ebean/Database.java index a7427a9dc6..bc3f0139b0 100644 --- a/ebean-api/src/main/java/io/ebean/Database.java +++ b/ebean-api/src/main/java/io/ebean/Database.java @@ -1227,22 +1227,53 @@ static DatabaseBuilder builder() { */ void insert(Object bean); + /** + * Insert the bean with options (ON CONFLICT DO UPDATE | DO NOTHING). + *

+ * Currently, this is limited to use with Postgres only, + *

+ * When using this ebean will look to determine the unique columns by looking at + * the mapping like {@code @Column(unique=true} and {@code @Index(unique=true}. + */ + void insert(Object bean, InsertOptions insertOptions); + /** * Insert the bean with a transaction. */ void insert(Object bean, Transaction transaction); + /** + * Insert the beans with options (ON CONFLICT DO UPDATE | DO NOTHING) and transaction. + *

+ * Currently, this is limited to use with Postgres only, + */ + void insert(Object bean, InsertOptions insertOptions, Transaction transaction); + /** * Insert a collection of beans. If there is no current transaction one is created and used to * insert all the beans in the collection. */ void insertAll(Collection beans); + /** + * Insert the beans with options - typically ON CONFLICT DO UPDATE | DO NOTHING. + *

+ * Currently, this is limited to use with Postgres only, + */ + void insertAll(Collection beans, InsertOptions options); + /** * Insert a collection of beans with an explicit transaction. */ void insertAll(Collection beans, Transaction transaction); + /** + * Insert the beans with options (ON CONFLICT DO UPDATE | DO NOTHING) and transaction. + *

+ * Currently, this is limited to use with Postgres only, + */ + void insertAll(Collection beans, InsertOptions options, Transaction transaction); + /** * Execute explicitly passing a transaction. */ diff --git a/ebean-api/src/main/java/io/ebean/InsertOptions.java b/ebean-api/src/main/java/io/ebean/InsertOptions.java new file mode 100644 index 0000000000..ec0c5ab3af --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/InsertOptions.java @@ -0,0 +1,119 @@ +package io.ebean; + +import io.avaje.lang.Nullable; + +/** + * Options to be used with insert such as ON CONFLICT DO UPDATE | NOTHING. + */ +public interface InsertOptions { + + /** + * Use ON CONFLICT UPDATE with automatic determination of the unique columns to conflict on. + *

+ * Uses mapping to determine the unique columns - {@code @Column(unique=true)} and {@code @Index(unique=true)} . + */ + InsertOptions ON_CONFLICT_UPDATE = InsertOptions.builder() + .onConflictUpdate() + .build(); + + /** + * Use ON CONFLICT DO NOTHING with automatic determination of the unique columns to conflict on. + *

+ * Uses mapping to determine the unique columns - {@code @Column(unique=true)} and {@code @Index(unique=true)} . + */ + InsertOptions ON_CONFLICT_NOTHING = InsertOptions.builder() + .onConflictNothing() + .build(); + + /** + * Return a builder for InsertOptions. + */ + static Builder builder() { + return new DInsertOptionsBuilder(); + } + + /** + * Return the constraint name that is used for ON CONFLICT. + */ + @Nullable + String constraint(); + + /** + * Return the unique columns that is used for ON CONFLICT. + *

+ * When not explicitly set will use mapping like {@code @Column(unique=true)} to determine the + * non-unique columns. + */ + @Nullable + String uniqueColumns(); + + /** + * Return the ON CONFLICT UPDATE SET clause. + *

+ * When not set will use the non-unique columns. + */ + @Nullable + String updateSet(); + + /** + * Return if GetGeneratedKeys should be used to fetch the generated keys after insert. + */ + @Nullable + Boolean getGetGeneratedKeys(); + + /** + * Return the key for these build options. + */ + String key(); + + /** + * The builder for InsertOptions. + */ + interface Builder { + + /** + * Use a ON CONFLICT UPDATE automatically determining the unique columns. + */ + Builder onConflictUpdate(); + + /** + * Use a ON CONFLICT DO NOTHING automatically determining the unique columns. + */ + Builder onConflictNothing(); + + /** + * Specify an explicit conflict constraint name. + *

+ * When this is used then unique columns will not be used. + */ + Builder constraint(String constraint); + + /** + * Specify the unique columns for the conflict target. + *

+ * When not specified and constraint is also not specified then + * it will automatically determine the unique columns + * based on mapping like {@code @Column(unique=true)} and + * {@code @Index(unique=true)} . + */ + Builder uniqueColumns(String uniqueColumns); + + /** + * Specify the ON CONFLICT DO UPDATE SET clause. + *

+ * When not specified ebean will include all the non-unique columns. + */ + Builder updateSet(String updateSet); + + /** + * Specify if GetGeneratedKeys should be used to return generated keys. + */ + Builder getGeneratedKeys(boolean getGeneratedKeys); + + /** + * Build and return the insert options. + */ + InsertOptions build(); + + } +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/core/DefaultServer.java b/ebean-core/src/main/java/io/ebeaninternal/server/core/DefaultServer.java index 8b2dc60e31..34fc985de6 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/core/DefaultServer.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/core/DefaultServer.java @@ -1651,41 +1651,50 @@ public void updateAll(@Nullable Collection beans, @Nullable Transaction trans }, transaction); } - /** - * Insert the bean. - */ @Override public void insert(Object bean) { - insert(bean, null); + persister.insert(checkEntityBean(bean), null, null); + } + + @Override + public void insert(Object bean, @Nullable InsertOptions insertOptions) { + persister.insert(checkEntityBean(bean), insertOptions, null); } - /** - * Insert the bean with a transaction. - */ @Override public void insert(Object bean, @Nullable Transaction transaction) { - persister.insert(checkEntityBean(bean), transaction); + persister.insert(checkEntityBean(bean), null, transaction); + } + + @Override + public void insert(Object bean, InsertOptions insertOptions, Transaction transaction) { + persister.insert(checkEntityBean(bean), insertOptions, transaction); } - /** - * Insert all beans in the collection. - */ @Override public void insertAll(Collection beans) { - insertAll(beans, null); + insertAll(beans, null, null); + } + + @Override + public void insertAll(Collection beans, InsertOptions options) { + insertAll(beans, options, null); } - /** - * Insert all beans in the collection with a transaction. - */ @Override public void insertAll(@Nullable Collection beans, @Nullable Transaction transaction) { + insertAll(beans, null, transaction); + } + + @Override + public void insertAll(@Nullable Collection beans, InsertOptions options, @Nullable Transaction transaction) { if (beans == null || beans.isEmpty()) { return; } executeInTrans((txn) -> { + txn.checkBatchEscalationOnCollection(); for (Object bean : beans) { - persister.insert(checkEntityBean(bean), txn); + persister.insert(checkEntityBean(bean), options, txn); } return 0; }, transaction); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java b/ebean-core/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java index 2d3a30bb5b..57ec8a2a0b 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java @@ -1,5 +1,6 @@ package io.ebeaninternal.server.core; +import io.ebean.InsertOptions; import io.ebean.ValuePair; import io.ebean.annotation.DocStoreMode; import io.ebean.bean.EntityBean; @@ -124,6 +125,7 @@ public final class PersistRequestBean extends PersistRequest implements BeanP * Many-to-many intersection table changes that are held for later batch processing. */ private List saveMany; + private InsertOptions insertOptions; public PersistRequestBean(SpiEbeanServer server, T bean, Object parentBean, BeanManager mgr, SpiTransaction t, PersistExecute persistExecute, PersistRequest.Type type, int flags) { @@ -1408,4 +1410,12 @@ public void setSaveRecurse() { private void setGeneratedId() { beanDescriptor.setGeneratedId(entityBean, transaction); } + + public void setInsertOptions(InsertOptions insertOptions) { + this.insertOptions = insertOptions; + } + + public InsertOptions insertOptions() { + return insertOptions; + } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/core/Persister.java b/ebean-core/src/main/java/io/ebeaninternal/server/core/Persister.java index 46927f1122..4728006da2 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/core/Persister.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/core/Persister.java @@ -1,11 +1,7 @@ package io.ebeaninternal.server.core; -import io.ebean.CallableSql; -import io.ebean.MergeOptions; -import io.ebean.Query; -import io.ebean.SqlUpdate; -import io.ebean.Transaction; -import io.ebean.Update; +import io.avaje.lang.Nullable; +import io.ebean.*; import io.ebean.bean.EntityBean; import io.ebean.meta.MetricVisitor; import io.ebeaninternal.api.SpiSqlUpdate; @@ -31,9 +27,9 @@ public interface Persister { void update(EntityBean entityBean, Transaction t); /** - * Force an Insert using the given bean. + * Perform an Insert using the given bean. */ - void insert(EntityBean entityBean, Transaction t); + void insert(EntityBean entityBean, @Nullable InsertOptions insertOptions, @Nullable Transaction t); /** * Insert or update the bean depending on its state. @@ -44,7 +40,6 @@ public interface Persister { * Delete a bean given it's type and id value. *

* This will also cascade delete one level of children. - *

*/ int delete(Class beanType, Object id, Transaction transaction, boolean permanent); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java index f2c9805dcd..c87d9e65b3 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java @@ -411,7 +411,7 @@ public void save(EntityBean bean, Transaction t) { if (bean._ebean_getIntercept().isUpdate()) { update(bean, t); } else { - insert(bean, t); + insert(bean, null, t); } } @@ -419,12 +419,15 @@ public void save(EntityBean bean, Transaction t) { * Insert this bean. */ @Override - public void insert(EntityBean bean, Transaction t) { + public void insert(EntityBean bean, InsertOptions insertOptions, Transaction t) { PersistRequestBean req = createRequest(bean, t, PersistRequest.Type.INSERT); if (req.isSkipReference()) { // skip insert on reference bean return; } + if (insertOptions != null) { + req.setInsertOptions(insertOptions); + } try { req.initTransIfRequiredWithBatchCascade(); insert(req); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/GenerateDmlRequest.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/GenerateDmlRequest.java index bf717d9687..9d5828dfd9 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/GenerateDmlRequest.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/GenerateDmlRequest.java @@ -1,11 +1,15 @@ package io.ebeaninternal.server.persist.dml; +import java.util.ArrayList; +import java.util.List; + /** * Helper to support the generation of DML statements. */ public final class GenerateDmlRequest { private final StringBuilder sb = new StringBuilder(100); + private final List columns = new ArrayList<>(); private StringBuilder insertBindBuffer; private String prefix; private String prefix2; @@ -26,7 +30,7 @@ public void appendColumn(String column, String bind) { ++bindColumnCount; sb.append(prefix); sb.append(column); - //sb.append(expr); + columns.add(column); if (insertMode > 0) { if (insertMode++ > 1) { insertBindBuffer.append(','); @@ -75,4 +79,8 @@ void setUpdateSetMode() { public boolean isUpdate() { return insertMode == 0; } + + public List columns() { + return columns; + } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertHandler.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertHandler.java index d734d1a51b..05f12fadbb 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertHandler.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertHandler.java @@ -69,7 +69,7 @@ public void bind() throws SQLException { SpiTransaction t = persistRequest.transaction(); // get the appropriate sql - sql = meta.getSql(withId, persistRequest.isPublish()); + sql = meta.sql(withId, persistRequest.isPublish(), persistRequest.insertOptions()); PreparedStatement pstmt; if (persistRequest.isBatched()) { pstmt = pstmtBatch(t, sql, persistRequest, useGeneratedKeys); @@ -134,7 +134,8 @@ private void setGeneratedKey(ResultSet rset) throws SQLException { if (idValue != null) { persistRequest.setGeneratedKey(idValue); } - } else { + } else if (persistRequest.insertOptions() == null) { + // insert on conflict do nothing can not return generated key throw new PersistenceException("Autoincrement getGeneratedKeys() returned no rows?"); } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMeta.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMeta.java index a4d9cd1f73..f55c074220 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMeta.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMeta.java @@ -1,5 +1,6 @@ package io.ebeaninternal.server.persist.dml; +import io.ebean.InsertOptions; import io.ebean.annotation.Platform; import io.ebean.bean.EntityBean; import io.ebean.config.dbplatform.DatabasePlatform; @@ -36,9 +37,11 @@ final class InsertMeta { private final Bindable shadowFKey; private final String[] identityDbColumns; private final Platform platform; + private final InsertMetaOptions options; InsertMeta(DatabasePlatform dbPlatform, BeanDescriptor desc, Bindable shadowFKey, BindableId id, BindableList all) { this.platform = dbPlatform.platform(); + this.options = InsertMetaPlatform.create(platform, desc, this); this.discriminator = discriminator(desc); this.id = id; this.all = all; @@ -47,8 +50,8 @@ final class InsertMeta { String tableName = desc.baseTable(); String draftTableName = desc.draftTable(); - this.sqlWithId = genSql(false, tableName, false); - this.sqlDraftWithId = desc.isDraftable() ? genSql(false, draftTableName, true) : sqlWithId; + this.sqlWithId = sql(false, tableName, false); + this.sqlDraftWithId = desc.isDraftable() ? sql(false, draftTableName, true) : sqlWithId; // only available for single Id property if (id.isConcatenated()) { @@ -72,8 +75,8 @@ final class InsertMeta { this.supportsGetGeneratedKeys = dbPlatform.dbIdentity().isSupportsGetGeneratedKeys(); this.supportsSelectLastInsertedId = desc.supportsSelectLastInsertedId(); } - this.sqlNullId = genSql(true, tableName, false); - this.sqlDraftNullId = desc.isDraftable() ? genSql(true, draftTableName, true) : sqlNullId; + this.sqlNullId = sql(true, tableName, false); + this.sqlDraftNullId = desc.isDraftable() ? sql(true, draftTableName, true) : sqlNullId; } } @@ -137,9 +140,20 @@ public void bind(DmlHandler request, EntityBean bean, boolean withId, boolean pu } /** - * get the sql based whether the id value(s) are null. + * Return the sql for the given options. */ - public String getSql(boolean withId, boolean publish) { + public String sql(boolean withId, boolean publish, InsertOptions insertOptions) { + if (insertOptions == null) { + return sql(withId, publish); + } + return options.sql(withId, insertOptions); + } + + String sqlFor(boolean withId) { + return withId ? sqlWithId : sqlNullId; + } + + private String sql(boolean withId, boolean publish) { if (withId) { return publish ? sqlWithId : sqlDraftWithId; } else { @@ -147,12 +161,18 @@ public String getSql(boolean withId, boolean publish) { } } - private String genSql(boolean nullId, String table, boolean draftTable) { + private String sql(boolean nullId, String table, boolean draftTable) { GenerateDmlRequest request = new GenerateDmlRequest(); + sql(request, nullId, table, draftTable); + return request.toString(); + } + + void sql(GenerateDmlRequest request, boolean nullId, String table, boolean draftTable) { request.setInsertSetMode(); request.append("insert into ").append(table); if (nullId && noColumnsForInsert(draftTable)) { - return request.append(defaultValues()).toString(); + request.append(defaultValues()); + return; } request.append(" ("); if (!nullId) { @@ -172,7 +192,6 @@ private String genSql(boolean nullId, String table, boolean draftTable) { request.append(") values ("); request.append(request.insertBindBuffer()); request.append(")"); - return request.toString(); } private String defaultValues() { @@ -196,5 +215,4 @@ private boolean noColumnsForInsert(boolean draftTable) { && discriminator == null && (draftTable ? all.isEmpty() : allExcludeDraftOnly.isEmpty()); } - } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMetaOptions.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMetaOptions.java new file mode 100644 index 0000000000..48476fffac --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMetaOptions.java @@ -0,0 +1,14 @@ +package io.ebeaninternal.server.persist.dml; + +import io.ebean.InsertOptions; + +/** + * Generator for insert SQL with options. + */ +interface InsertMetaOptions { + + /** + * Generate the SQL for the given insert options. + */ + String sql(boolean withId, InsertOptions insertOptions); +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMetaOptionsPostgres.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMetaOptionsPostgres.java new file mode 100644 index 0000000000..484a4ba871 --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMetaOptionsPostgres.java @@ -0,0 +1,113 @@ +package io.ebeaninternal.server.persist.dml; + +import io.ebean.InsertOptions; +import io.ebeaninternal.server.deploy.BeanDescriptor; +import io.ebeaninternal.server.deploy.BeanProperty; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * Postgres specific generation of insert on conflict. + */ +final class InsertMetaOptionsPostgres implements InsertMetaOptions { + + private final InsertMeta meta; + private final BeanDescriptor desc; + private final String baseTable; + private final Map sqlCache = new ConcurrentHashMap<>(); + + InsertMetaOptionsPostgres(InsertMeta meta, BeanDescriptor desc) { + this.meta = meta; + this.desc = desc; + this.baseTable = desc.baseTable(); + } + + @Override + public String sql(boolean withId, InsertOptions options) { + String key = withId + options.key(); + return sqlCache.computeIfAbsent(key, k -> generate(withId, options)); + } + + private String generate(boolean withId, InsertOptions options) { + char type = options.key().charAt(0); + switch (type) { + case 'U': + return generate(withId, false, options); + case 'N': + return generate(withId, true, options); + default: + return meta.sqlFor(withId); + } + } + + private String generate(boolean withId, boolean doNothing, InsertOptions options) { + GenerateDmlRequest request = new GenerateDmlRequest(); + meta.sql(request, !withId, baseTable, false); + request.append(" on conflict "); + + List uniqueColumns = desc.uniqueProps().stream() + .flatMap(Arrays::stream) + .map(BeanProperty::dbColumn) + .collect(Collectors.toList()); + + String constraintName = options.constraint(); + if (constraintName != null) { + request.append("on constraint ").append(constraintName); + } else { + request.append("("); + String cols = options.uniqueColumns(); + if (cols != null) { + request.append(cols); + } else { + appendUniqueColumns(uniqueColumns, request); + } + request.append(")"); + } + if (doNothing) { + request.append(" do nothing"); + return request.toString(); + } + request.append(" do update set "); + String updateSet = options.updateSet(); + if (updateSet != null) { + request.append(updateSet); + } else { + setColumns(withId, request, uniqueColumns); + } + return request.toString(); + } + + private void setColumns(boolean withId, GenerateDmlRequest request, List uniqueColumns) { + List columns = request.columns(); + columns.removeAll(uniqueColumns); + if (withId) { + BeanProperty idProperty = desc.idProperty(); + if (idProperty != null && !idProperty.isEmbedded()) { + columns.remove(idProperty.dbColumn()); + } + } + for (int i = 0; i < columns.size(); i++) { + if (i > 0) { + request.append(", "); + } + String col = columns.get(i); + request.append(col).append("=excluded.").append(col); + } + } + + private static void appendUniqueColumns(List uniqueColumns, GenerateDmlRequest request) { + if (uniqueColumns.isEmpty()) { + throw new IllegalStateException("Unable to identify unique columns for INSERT ON CONFLICT - Add mapping like @Column(unique=true) or @Index(unique=true)"); + } + for (int i = 0; i < uniqueColumns.size(); i++) { + if (i > 0) { + request.append(", "); + } + request.append(uniqueColumns.get(i)); + } + } +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMetaPlatform.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMetaPlatform.java new file mode 100644 index 0000000000..fd82f0772f --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/InsertMetaPlatform.java @@ -0,0 +1,29 @@ +package io.ebeaninternal.server.persist.dml; + +import io.ebean.InsertOptions; +import io.ebean.annotation.Platform; +import io.ebeaninternal.server.deploy.BeanDescriptor; + +final class InsertMetaPlatform { + + private static final NotSupported NOT_SUPPORTED = new NotSupported(); + + static InsertMetaOptions create(Platform platform, BeanDescriptor desc, InsertMeta meta) { + switch (platform.base()) { + case POSTGRES: + case YUGABYTE: + case COCKROACH: + return new InsertMetaOptionsPostgres(meta, desc); + default: + return NOT_SUPPORTED; + } + } + + static final class NotSupported implements InsertMetaOptions { + @Override + public String sql(boolean withId, InsertOptions insertOptions) { + throw new UnsupportedOperationException("InsertOptions not supported on this database platform"); + } + } + +} diff --git a/ebean-test/src/test/java/io/ebean/xtest/internal/api/TDSpiServer.java b/ebean-test/src/test/java/io/ebean/xtest/internal/api/TDSpiServer.java index 6efedcacbb..fefece8476 100644 --- a/ebean-test/src/test/java/io/ebean/xtest/internal/api/TDSpiServer.java +++ b/ebean-test/src/test/java/io/ebean/xtest/internal/api/TDSpiServer.java @@ -453,21 +453,41 @@ public void insert(Object bean) { } + @Override + public void insert(Object bean, InsertOptions insertOptions) { + + } + @Override public void insert(Object bean, Transaction transaction) { } + @Override + public void insert(Object bean, InsertOptions insertOptions, Transaction transaction) { + + } + @Override public void insertAll(Collection beans) { } + @Override + public void insertAll(Collection beans, InsertOptions options) { + + } + @Override public void insertAll(Collection beans, Transaction transaction) { } + @Override + public void insertAll(Collection beans, InsertOptions options, Transaction transaction) { + + } + @Override public int execute(SqlUpdate updSql, Transaction transaction) { return 0; diff --git a/ebean-test/src/test/java/org/tests/insert/TestInsertOnConflict.java b/ebean-test/src/test/java/org/tests/insert/TestInsertOnConflict.java new file mode 100644 index 0000000000..4490a87ef0 --- /dev/null +++ b/ebean-test/src/test/java/org/tests/insert/TestInsertOnConflict.java @@ -0,0 +1,297 @@ +package org.tests.insert; + +import io.ebean.DB; +import io.ebean.Database; +import io.ebean.InsertOptions; +import io.ebean.Transaction; +import io.ebean.annotation.Platform; +import io.ebean.test.LoggedSql; +import io.ebean.xtest.BaseTestCase; +import io.ebean.xtest.ForPlatform; +import org.junit.jupiter.api.Test; +import org.tests.update.EPersonOnline; + +import java.util.List; + +import static io.ebean.InsertOptions.ON_CONFLICT_NOTHING; +import static io.ebean.InsertOptions.ON_CONFLICT_UPDATE; +import static org.assertj.core.api.Assertions.assertThat; + +class TestInsertOnConflict extends BaseTestCase { + + InsertOptions onConflictDoUpdateAndGetGeneratedKeys = InsertOptions.builder() + .onConflictUpdate() + .getGeneratedKeys(true) + .build(); + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void insertOnConflictUpdateExplicitTransaction() { + Database db = DB.getDefault(); + db.truncate(EPersonOnline.class); + LoggedSql.start(); + + var bean = newBean("a@b.com"); + + try (Transaction txn = DB.createTransaction()) { + db.insert(bean, ON_CONFLICT_UPDATE, txn); + txn.commit(); + } + assertThat(bean.getId()).isNotNull(); + + var bean2 = newBean("a@b.com"); + bean2.setOnlineStatus(false); + try (Transaction txn = DB.createTransaction()) { + db.insert(bean2, ON_CONFLICT_UPDATE, txn); + txn.commit(); + } + assertThat(bean2.getId()).isEqualTo(bean.getId()); + + var sql = LoggedSql.stop(); + assertThat(sql).hasSize(2); + assertThat(sql.get(0)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict (email) do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + assertThat(sql.get(1)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict (email) do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + + List list = db.find(EPersonOnline.class).findList(); + assertThat(list).hasSize(1); + assertThat(list.get(0).getWhenUpdated()).isEqualTo(bean2.getWhenUpdated()); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void insertOnConflictUpdate_when_noIdValue() { + Database db = DB.getDefault(); + db.truncate(EPersonOnline.class); + LoggedSql.start(); + + var bean = newBean("a@b.com"); + db.insert(bean, onConflictDoUpdateAndGetGeneratedKeys); + assertThat(bean.getId()).isNotNull(); + + var bean2 = newBean("a@b.com"); + bean2.setOnlineStatus(false); + db.insert(bean2, onConflictDoUpdateAndGetGeneratedKeys); + assertThat(bean2.getId()).isEqualTo(bean.getId()); + + var sql = LoggedSql.stop(); + assertThat(sql).hasSize(2); + assertThat(sql.get(0)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict (email) do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + assertThat(sql.get(1)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict (email) do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + + List list = db.find(EPersonOnline.class).findList(); + + assertThat(list).hasSize(1); + assertThat(list.get(0).getWhenUpdated()).isEqualTo(bean2.getWhenUpdated()); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void insertOnConflictUpdate_when_idValueSupplied() { + Database db = DB.getDefault(); + db.truncate(EPersonOnline.class); + LoggedSql.start(); + + var bean = newBean("a@b.com"); + bean.setId(40_042L); + db.insert(bean, ON_CONFLICT_UPDATE); + assertThat(bean.getId()).isNotNull(); + + var bean2 = newBean("a@b.com"); + bean2.setId(40_043L); // not expected but can be different + bean2.setOnlineStatus(false); + + db.insert(bean2, onConflictDoUpdateAndGetGeneratedKeys); + + var sql = LoggedSql.stop(); + assertThat(sql).hasSize(2); + assertThat(sql.get(0)).contains("insert into e_person_online (id, email, online_status, when_updated) values (?,?,?,?) on conflict (email) do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + assertThat(sql.get(1)).contains("insert into e_person_online (id, email, online_status, when_updated) values (?,?,?,?) on conflict (email) do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + + List list = db.find(EPersonOnline.class).findList(); + + assertThat(list).hasSize(1); + assertThat(list.get(0).getWhenUpdated()).isEqualTo(bean2.getWhenUpdated()); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void insertAll_onConflictUpdate_when_noIdValue() { + Database db = DB.getDefault(); + db.truncate(EPersonOnline.class); + LoggedSql.start(); + + var bean = newBean("a1@b.com"); + var bean2 = newBean("a2@b.com"); + var bean3 = newBean("a3@b.com"); + db.insertAll(List.of(bean, bean2, bean3), ON_CONFLICT_UPDATE); + + var sql = LoggedSql.stop(); + assertThat(sql).hasSize(5); + assertThat(sql.get(0)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict (email) do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + assertThat(sql.get(1)).contains(" -- bind"); + assertThat(sql.get(2)).contains(" -- bind"); + assertThat(sql.get(3)).contains(" -- bind"); + assertThat(sql.get(4)).contains(" -- executeBatch()"); + + var bean4 = newBean("a1@b.com"); + var bean5 = newBean("a5@b.com"); + db.insertAll(List.of(bean4, bean5), ON_CONFLICT_UPDATE); + + List list = db.find(EPersonOnline.class).orderBy("id").findList(); + assertThat(list).hasSize(4); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void insertAll_onConflictUpdate_explicitTransaction() { + Database db = DB.getDefault(); + db.truncate(EPersonOnline.class); + LoggedSql.start(); + + try (Transaction txn = DB.createTransaction()) { + txn.setBatchSize(3); + var bean = newBean("a1@b.com"); + var bean2 = newBean("a2@b.com"); + var bean3 = newBean("a3@b.com"); + var bean4 = newBean("a4@b.com"); + db.insertAll(List.of(bean, bean2, bean3, bean4), ON_CONFLICT_UPDATE, txn); + txn.commit(); + } + + var sql = LoggedSql.stop(); + assertThat(sql).hasSize(8); + assertThat(sql.get(0)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict (email) do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + assertThat(sql.get(1)).contains(" -- bind"); + assertThat(sql.get(2)).contains(" -- bind"); + assertThat(sql.get(3)).contains(" -- bind"); + assertThat(sql.get(4)).contains(" -- executeBatch()"); + assertThat(sql.get(5)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict (email) do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + assertThat(sql.get(6)).contains(" -- bind"); + assertThat(sql.get(7)).contains(" -- executeBatch()"); + + List list = db.find(EPersonOnline.class).orderBy("id").findList(); + assertThat(list).hasSize(4); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void explicitConstraint() { + Database db = DB.getDefault(); + db.truncate(EPersonOnline.class); + LoggedSql.start(); + + var bean = newBean("a1@b.com"); + var bean2 = newBean("a2@b.com"); + var bean3 = newBean("a3@b.com"); + + var ON_CONFLICT_ = InsertOptions.builder() + .onConflictUpdate() + .constraint("uq_e_person_online_email") + .build(); + + db.insertAll(List.of(bean, bean2, bean3), ON_CONFLICT_); + + var sql = LoggedSql.stop(); + assertThat(sql).hasSize(5); + assertThat(sql.get(0)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict on constraint uq_e_person_online_email do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + assertThat(sql.get(1)).contains(" -- bind"); + assertThat(sql.get(2)).contains(" -- bind"); + assertThat(sql.get(3)).contains(" -- bind"); + assertThat(sql.get(4)).contains(" -- executeBatch()"); + + List list = db.find(EPersonOnline.class).orderBy("id").findList(); + assertThat(list).hasSize(3); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void explicitUniqueColumns() { + Database db = DB.getDefault(); + db.truncate(EPersonOnline.class); + LoggedSql.start(); + + var bean = newBean("a1@b.com"); + var bean2 = newBean("a2@b.com"); + var bean3 = newBean("a3@b.com"); + + var ON_CONFLICT_ = InsertOptions.builder() + .onConflictUpdate() + .uniqueColumns(" email ") + .build(); + + db.insertAll(List.of(bean, bean2, bean3), ON_CONFLICT_); + + var sql = LoggedSql.stop(); + assertThat(sql).hasSize(5); + assertThat(sql.get(0)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict ( email ) do update set online_status=excluded.online_status, when_updated=excluded.when_updated"); + assertThat(sql.get(1)).contains(" -- bind"); + assertThat(sql.get(2)).contains(" -- bind"); + assertThat(sql.get(3)).contains(" -- bind"); + assertThat(sql.get(4)).contains(" -- executeBatch()"); + + List list = db.find(EPersonOnline.class).orderBy("id").findList(); + assertThat(list).hasSize(3); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void explicitUpdateSet() { + Database db = DB.getDefault(); + db.truncate(EPersonOnline.class); + LoggedSql.start(); + + var bean = newBean("a1@b.com"); + var bean2 = newBean("a2@b.com"); + + var ON_CONFLICT_ = InsertOptions.builder() + .onConflictUpdate() + .updateSet("when_updated=excluded.when_updated, online_status=true") + .build(); + + db.insertAll(List.of(bean, bean2), ON_CONFLICT_); + + var sql = LoggedSql.stop(); + assertThat(sql).hasSize(4); + assertThat(sql.get(0)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict (email) do update set when_updated=excluded.when_updated, online_status=true"); + assertThat(sql.get(1)).contains(" -- bind"); + assertThat(sql.get(2)).contains(" -- bind"); + assertThat(sql.get(3)).contains(" -- executeBatch()"); + + List list = db.find(EPersonOnline.class).orderBy("id").findList(); + assertThat(list).hasSize(2); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void insertOnConflictNothing_when_noIdValue() { + Database db = DB.getDefault(); + db.truncate(EPersonOnline.class); + LoggedSql.start(); + + var bean = newBean("a@b.com"); + db.insert(bean, ON_CONFLICT_NOTHING); + assertThat(bean.getId()).isNotNull(); + + var bean2 = newBean("a@b.com"); + bean2.setOnlineStatus(false); + db.insert(bean2, ON_CONFLICT_NOTHING); + assertThat(bean2.getId()).isNull(); + + var sql = LoggedSql.stop(); + assertThat(sql).hasSize(2); + assertThat(sql.get(0)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict (email) do nothing"); + assertThat(sql.get(1)).contains("insert into e_person_online (email, online_status, when_updated) values (?,?,?) on conflict (email) do nothing"); + + List list = db.find(EPersonOnline.class).findList(); + + assertThat(list).hasSize(1); + assertThat(list.get(0).getWhenUpdated()).isEqualTo(bean.getWhenUpdated()); + } + + private static EPersonOnline newBean(String email) { + EPersonOnline bean = new EPersonOnline(); + bean.setEmail(email); + bean.setOnlineStatus(true); + return bean; + } +}