Skip to content

Inserts

Incubating

Inserts are new in 0.3.0 and @Incubating, like the other bulk writes: complete and tested, but their API may still change in a minor release. See API stability.

Four calls write new rows, each one statement per chunk and none of them an entity write except persist:

Call Use it for
insert(ModelInsert) with a select source Insert-select: write the rows a query returns, into another table.
insert(ModelInsert) with rows Insert-values: write rows you hold, as multi-row VALUES statements.
insertReturningKeys(ValuesInsert) Insert-values that gives back the generated keys, in row order.
persist(ModelPersist) One row through JPA, with its lifecycle callbacks; the only portable way to get an IDENTITY key.

Every example below is copied from a test that runs; each block names its test. Like update and delete, an insert never loads an entity, and the persistence-context rules are those of Bulk writes: the context is flushed first and cleared afterwards unless you ask for PersistenceContextMode.KEEP.

Insert models

An insert model lists the attributes of an entity a row writes (@InsertModel, with the processor generating QNewBook.insert(rows) and QNewBook.persist(row); see the processor guide). Attributes it leaves out take the database default under insert, and the value the no-argument constructor gives under persist. When the entity's id has no generator the model names it; when it has one the model leaves it out. Values pass through the column's converter and are always bind parameters.

Insert-select

ModelInsert.select(columns, sourceRoot) maps source columns, which may sit on joins and to-many paths, onto the target's columns. It writes exactly the rows the equivalent list would return, duplicates from a to-many join included. Test: InsertSelectTest.ac_wrt_21_an_insert_select_writes_the_rows_the_list_returns_with_joined_and_to_many_columns.

ModelInsert.select(ARCHIVE_COLUMNS, ORDERS)
        .map(ARCHIVE_ID, LINE_ITEM_ID)
        .map(ARCHIVE_ORDER_ID, LINE_ORDER_ID)
        .map(ARCHIVE_CUSTOMER, LINE_CUSTOMER_ID)
        .map(ARCHIVE_STATUS, LINE_STATUS)
        .map(ARCHIVE_CUSTOMER_NAME, LINE_CUSTOMER_NAME)
        .map(ARCHIVE_PRODUCT, LINE_PRODUCT)
        .set(ARCHIVED_BY, "tck")            // a constant for a column the model leaves out
        .where(PAID_IN_DE)
// ... then, in the test:
written[0] = archives(em).insert(archive(PAID_IN_DE).build());

Chunked

Add chunked(ChunkOptions) to write in key-first chunks over the distinct source ids, so each source row is written once. Test: InsertSelectTest.ac_wrt_21_a_chunked_insert_select_over_a_separate_target_writes_each_source_row_once.

written[0] = archives(em).insert(archive(PAID_IN_DE).chunked(ChunkOptions.size(5)).build());

If the target overlaps the source (the same entity or a shared table), the provider cannot name the tables, or the source joins through a link or collection table (a @ManyToMany, an @ElementCollection, or a @OneToMany over a join table), a chunked insert-select fails with MQ1806 before the flush; guard such a copy with notExists over the target, or leave it unchunked. A @OneToMany with mappedBy or a join column, and any to-one join, stay allowed. Test: InsertSelectTest.ac_wrt_21_a_chunked_insert_select_over_its_own_entity_or_a_shared_table_throws_mq1806_before_the_flush. An insert-select needs a generator Hibernate renders inline: a pooled sequence or a JOINED root fails with MQ1805.

Insert-values

ValuesInsert.builder(columns, keyType, rows) writes the rows you hold, in list order. Rows per statement stay within the vendor's bind and VALUES limits. An empty list runs no SQL. Test: InsertValuesTest.ac_wrt_22_insert_values_with_an_assigned_id_a_converter_a_to_one_by_id_and_a_set_constant_round_trips.

var insert = ValuesInsert.builder(NEW_ARCHIVE, Long.class, rows).set(ARCHIVED_BY, "tck").build();
written[0] = archives(em, ModelQueryConfig.defaults()).insert(insert);

The library does not validate rows. A to-one column binds its target's id, so no row is loaded.

Returning keys

insertReturningKeys takes a ValuesInsert and returns the keys in row order. The keys are drawn before the insert, so it works for pooled and database sequences, tables and UUIDs; an IDENTITY or assigned id, or a K that is not the id's type, fails with MQ1807 (use persist for IDENTITY). It cannot be combined with a conflict clause (it does not compile) or commitEachChunk() (MQ1801). Test: InsertValuesTest.ac_wrt_23_insert_returning_keys_returns_drawn_keys_that_read_back_each_row_by_index.

List<K> keys = executor(em, root).insertReturningKeys(coded(root, keyType, rows)
        .chunked(ChunkOptions.size(2)).build());

Chunks and failures

ChunkOptions.commitEachChunk() commits each chunk on its own. When one fails, ChunkedWriteException reports committedRows(), nextRowIndex() to resume from, and inDoubtRowCount(). Test: InsertValuesTest.ac_wrt_26_a_per_chunk_insert_values_failure_reports_committed_rows_and_the_next_row_index.

var insert = ValuesInsert.builder(BARE_ARCHIVE, Long.class, bareRows(7))
        .chunked(ChunkOptions.size(2).commitEachChunk()).build();
// the third chunk fails:
assertThat(failure[0].committedRows()).isEqualTo(4);
assertThat(failure[0].nextRowIndex()).hasValue(4);

Conflict clauses

onConflict(columns...) makes an insert-values conditional: one statement per chunk, and a conflict is detected on the named key, which must be the id, a natural id or a declared unique constraint in the mapping (MQ1804 otherwise). The key is trusted from the mapping, so the database must enforce it: MERGE vendors (H2, Oracle, SQL Server) and MySQL with anyUniqueKey() do not detect a key the schema does not enforce, and would update every matching row or insert duplicates. It is offered on insert-values only; an insert-select guards with notExists. Two rows of one call that share a conflict key throw MQ1808 at build(). The count returned is the vendor's: MySQL counts a skipped or changed row differently from PostgreSQL and H2, and the tests pin each.

doNothing

Test: InsertConflictTest.ac_wrt_25_do_nothing_skips_a_conflicting_row_counting_as_the_vendor_does.

var insert = ValuesInsert.builder(NAMED, Long.class, List.of(new Named(2L, "c2", "X2"),
        new Named(3L, "c3", "N3"))).onConflict(ID).doNothing().anyUniqueKey().build();

Where the provider does not render doNothing (H2 on Hibernate 6.6), the first execution fails with MQ1804.

doUpdate

doUpdate assigns with setFromRow (the incoming value), set, or setNull, optionally limited by one where; the @Version is incremented unless keepVersion(). Test: InsertConflictTest.ac_wrt_25_do_update_from_the_row_where_the_stored_row_matches_increments_the_version.

var insert = ValuesInsert.builder(NAMED, Long.class, List.of(new Named(1L, "c1", "Y1"),
                new Named(2L, "c2", "Y2"), new Named(3L, "c3", "N3")))
        .onConflict(ID).doUpdate(u -> u.setFromRow(NAME).where(f -> f.eq(NAME, "N2"))).anyUniqueKey().build();

Test: InsertConflictTest.ac_wrt_25_do_update_sets_a_value_or_null_on_a_unique_column_conflict_and_keep_version_keeps_it.

var kept = ValuesInsert.builder(NAMED, Long.class, rows)
        .onConflict(CODE).doUpdate(u -> u.set(NAME, "Z")).keepVersion().anyUniqueKey().build();
var nulled = ValuesInsert.builder(NAMED, Long.class, rows.subList(0, 1))
        .onConflict(CODE).doUpdate(u -> u.setNull(NAME)).anyUniqueKey().build();

anyUniqueKey()

MySQL and MariaDB detect a conflict on any unique key, not the one you named. So the named key is honoured or the call fails: on those vendors a conflict clause without anyUniqueKey() throws MQ1804 before any statement, and with it you state that any unique key may trigger the clause. Test: InsertConflictTest.ac_wrt_25_mysql_without_any_unique_key_throws_mq1804_before_any_statement.

conflictUpdateWhereOnAssignedColumns

A where that reads two or more columns the update assigns is refused with MQ1804 by default, because vendors disagree on whether the filter sees the stored or the new value. The executor option ModelQueryConfig.conflictUpdateWhereOnAssignedColumns(true) allows it where the filter reads the stored row, and a vendor that cannot (MySQL) still throws MQ1804. Tests: InsertConflictTest.ac_wrt_25_a_where_reading_two_assigned_columns_throws_mq1804_by_default_on_every_vendor and InsertConflictTest.ac_wrt_25_with_the_option_a_where_reading_two_assigned_columns_reads_the_stored_row_or_throws_mq1804.

var config = ModelQueryConfig.defaults().conflictUpdateWhereOnAssignedColumns(true);
var insert = ValuesInsert.builder(NAMED, Long.class, List.of(new Named(1L, "c1x", "Y1"),
                new Named(2L, "c2x", "X2"), new Named(3L, "c3", "N3")))
        .onConflict(ID).doUpdate(u -> u.setFromRow(CODE, NAME).where(f -> f.eq(CODE, "c2").eq(NAME, "N2")))
        .anyUniqueKey().build();

persist

persist writes one row through JPA: it instantiates the entity, sets each attribute, persists, flushes and detaches only the entity it created. Lifecycle callbacks such as @PrePersist run, and IDENTITY keys come back. It needs a transaction (MQ2501 without one) and takes no set, conflict clause or chunking. It also runs on a provider with no insert support. Test: PersistTest.ac_wrt_28_persist_with_identity_returns_the_key_runs_pre_persist_and_leaves_the_entity_detached.

var persist = ModelPersist.of(COLUMNS, Long.class,
        new NewPersist("p1", InsPersistEntity.Status.PAID, true, "#42", "Hanoi", "100000", 2L));
Long key = executor(em, InsPersistEntity.class).persist(persist);

A CascadeType.ALL to-one detaches the caller's managed target as well, as the mapping's cascade says.

The entity is detached even when the flush fails. Before any statement, a model writing part of a composite id is MQ1802, and so, on Hibernate, is one naming an id its generator generates; a null set on a primitive attribute is MQ1308. An embeddable with no no-argument constructor is MQ1805 under persist; insert writes it. Tests: PersistTest.ac_wrt_26_persist_naming_a_generated_id_or_part_of_a_composite_id_throws_mq1802_before_any_statement, PersistTest.ac_wrt_28_persist_of_null_to_a_primitive_attribute_throws_mq1308_before_any_statement.

Returning a model

persist(persist, returning) gives back a query model instead of the key. The model is built from the flushed entity before it is detached, with no second select: the generated id, what @PrePersist and the entity's constructor set, and any write assignment value are in it. The query supplies only the selection, the mapper, afterMap and the finisher, and it can fill root attributes, embeddable paths and the id of a to-one association, which it reads without initializing the target. A query with a where or having that recorded a filter, groupBy, a fetch plan, customize, orderBy, keyset or primaryKeyFirst, or a column the entity cannot fill alone (an expression, an aggregate, a join beyond a to-one id or with on(...), a collection), is MQ1809 on its first execution per factory, before any statement. A value the database fills, such as a column default or a trigger, is in the model only where the mapping has the provider read it back (@Generated). Test: PersistReturningTest.ac_wrt_38_persist_returning_fills_the_generated_id_pre_persist_and_constructor_values_with_one_insert.

private static final ModelQuery<InsPersistEntity, Long, InsPersistView> VIEW = QInsPersistView.query()
        .select(QInsPersistView.ALL.with(QInsPersistView.SOURCE)).build();

var persist = ModelPersist.of(COLUMNS, Long.class,
        new NewPersist("p1", InsPersistEntity.Status.PAID, true, "#42", "Hanoi", 2L));
// Needs an active transaction (MQ2501 without one).
InsPersistView view = executor(em).persist(persist, VIEW);
// Joins the current transaction, or opens one and commits it before returning.
InsPersistView view = repository.persist(persist, VIEW);

The same overload is on ModelQueryRepository (see below). There is no returning(...) builder stage, and no entity-mode insert: persist in a loop is the way to fire listeners for many rows.

With Spring Data

ModelQueryRepository has insert, insertReturningKeys and persist, with persist(persist, returning) as its second form. Each opens a transaction on the repository's transaction manager when none is active, so the rows are committed when the call returns, and joins an active one (R-SPR-10). Test: ModelQueryRepositoryTest.ac_wrt_30_insert_insert_returning_keys_and_persist_without_a_transaction_commit_through_the_repository.

ModelInsert<InsUuidEntity, ?> values = ValuesInsert.builder(CODED_UUIDS, UUID.class,
        List.of(new Coded("a", "A"), new Coded("b", "B"))).build();
assertThat(uuids.insert(values)).isEqualTo(2);
List<UUID> keys = uuids.insertReturningKeys(ValuesInsert.builder(CODED_UUIDS, UUID.class,
        List.of(new Coded("c", "C"), new Coded("d", "D"), new Coded("e", "E"))).build());
Long key = persisted.persist(ModelPersist.of(CODED_PERSIST, Long.class, new Coded("p", null)));

The Spring Boot sample has both endpoints. A POST creating one book through persist and answering with the persisted model, tested by SampleApplicationTest.ac_spr_09_the_post_endpoint_persists_one_book_without_a_surrounding_transaction:

private static final ModelQuery<BookEntity, Long, SavedBook> SAVED = QSavedBook.query()
        .select(QSavedBook.ALL)
        .build();

@PostMapping("/books")
ResponseEntity<SavedBook> create(@RequestBody NewBook book) {
    SavedBook saved = books.persist(QNewBook.persist(book), SAVED);
    return ResponseEntity.created(URI.create("/books/" + saved.id())).body(saved);
}

and an import that skips rows whose id exists, tested by SampleApplicationTest.ac_spr_09_the_import_endpoint_inserts_films_skipping_existing_ids:

@PostMapping("/films/import")
Imported importFilms(@RequestBody List<NewFilm> rows) {
    var insert = QNewFilm.insert(rows).onConflict(QNewFilm.ID).doNothing().build();
    return new Imported(films.insert(insert));
}