Persisting Entities

R2dbcEntityTemplate is the central entrypoint for Spring Data R2DBC. It provides direct entity-oriented methods and a more narrow, fluent interface for typical ad-hoc use-cases, such as querying, inserting, updating, and deleting data.

The entry points (insert(), select(), update(), and others) follow a natural naming schema based on the operation to be run. Moving on from the entry point, the API is designed to offer only context-dependent methods that lead to a terminating method that creates and runs a SQL statement. Spring Data R2DBC uses a R2dbcDialect abstraction to determine bind markers, pagination support and the data types natively supported by the underlying driver.

All terminal methods return always a Publisher type that represents the desired operation. The actual statements are sent to the database upon subscription.

Methods for Inserting and Updating Entities

There are several convenient methods on R2dbcEntityTemplate for saving and inserting your objects. To have more fine-grained control over the conversion process, you can register Spring converters with R2dbcCustomConversions — for example Converter<Person, OutboundRow> and Converter<Row, Person>.

The simple case of using the save operation is to save a POJO. In this case, the table name is determined by name (not fully qualified) of the class. You may also call the save operation with a specific collection name. You can use mapping metadata to override the collection in which to store the object.

When inserting or saving, if the Id property is not set, the assumption is that its value will be auto-generated by the database. Consequently, for auto-generation the type of the Id property or field in your class must be a Long, or Integer.

The following example shows how to insert a row and retrieving its contents:

Inserting and retrieving entities using the R2dbcEntityTemplate
Person person = new Person("John", "Doe");

Mono<Person> saved = template.insert(person);
Mono<Person> loaded = template.selectOne(query(where("firstname").is("John")),
		Person.class);

The following insert and update operations are available:

A similar set of insert operations is also available:

  • Mono<T> insert (T objectToSave): Insert the object to the default table.

  • Mono<T> update (T objectToSave): Insert the object to the default table.

Table names can be customized by using the fluent API.

Selecting Data

The select(…) and selectOne(…) methods on R2dbcEntityTemplate are used to select data from a table. Both methods take a Query object that defines the field projection, the WHERE clause, the ORDER BY clause and limit/offset pagination. Limit/offset functionality is transparent to the application regardless of the underlying database. This functionality is supported by the R2dbcDialect abstraction to cater for differences between the individual SQL flavors.

Selecting entities using the R2dbcEntityTemplate
Flux<Person> loaded = template.select(query(where("firstname").is("John")),
		Person.class);

Fluent API

This section explains the fluent API usage. Consider the following simple query:

Flux<Person> people = template.select(Person.class) (1)
		.all(); (2)
1 Using Person with the select(…) method maps tabular results on Person result objects.
2 Fetching all() rows returns a Flux<Person> without limiting results.

The following example declares a more complex query that specifies the table name by name, a WHERE condition, and an ORDER BY clause:

Mono<Person> first = template.select(Person.class)	(1)
	.from("other_person")
	.matching(query(where("firstname").is("John")			(2)
		.and("lastname").in("Doe", "White"))
	  .sort(by(desc("id"))))													(3)
	.one();																						(4)
1 Selecting from a table by name returns row results using the given domain type.
2 The issued query declares a WHERE condition on firstname and lastname columns to filter results.
3 Results can be ordered by individual column names, resulting in an ORDER BY clause.
4 Selecting the one result fetches only a single row. This way of consuming rows expects the query to return exactly a single result. Mono emits a IncorrectResultSizeDataAccessException if the query yields more than a single result.
You can directly apply Projections to results by providing the target type via select(Class<?>).

You can switch between retrieving a single entity and retrieving multiple entities through the following terminating methods:

  • first(): Consume only the first row, returning a Mono. The returned Mono completes without emitting an object if the query returns no results.

  • one(): Consume exactly one row, returning a Mono. The returned Mono completes without emitting an object if the query returns no results. If the query returns more than one row, Mono completes exceptionally emitting IncorrectResultSizeDataAccessException.

  • all(): Consume all returned rows returning a Flux.

  • count(): Apply a count projection returning Mono<Long>.

  • exists(): Return whether the query yields any rows by returning Mono<Boolean>.

You can use the select() entry point to express your SELECT queries. The resulting SELECT queries support the commonly used clauses (WHERE and ORDER BY) and support pagination. The fluent API style let you chain together multiple methods while having easy-to-understand code. To improve readability, you can use static imports that let you avoid using the 'new' keyword for creating Criteria instances.

Methods for the Criteria Class

The Criteria class provides the following methods, all of which correspond to SQL operators:

  • Criteria and (String column): Adds a chained Criteria with the specified property to the current Criteria and returns the newly created one.

  • Criteria or (String column): Adds a chained Criteria with the specified property to the current Criteria and returns the newly created one.

  • Criteria greaterThan (Object o): Creates a criterion by using the > operator.

  • Criteria greaterThanOrEquals (Object o): Creates a criterion by using the >= operator.

  • Criteria in (Object…​ o): Creates a criterion by using the IN operator for a varargs argument.

  • Criteria in (Collection<?> collection): Creates a criterion by using the IN operator using a collection.

  • Criteria is (Object o): Creates a criterion by using column matching (property = value).

  • Criteria isNull (): Creates a criterion by using the IS NULL operator.

  • Criteria isNotNull (): Creates a criterion by using the IS NOT NULL operator.

  • Criteria lessThan (Object o): Creates a criterion by using the < operator.

  • Criteria lessThanOrEquals (Object o): Creates a criterion by using the operator.

  • Criteria like (Object o): Creates a criterion by using the LIKE operator without escape character processing.

  • Criteria not (Object o): Creates a criterion by using the != operator.

  • Criteria notIn (Object…​ o): Creates a criterion by using the NOT IN operator for a varargs argument.

  • Criteria notIn (Collection<?> collection): Creates a criterion by using the NOT IN operator using a collection.

You can use Criteria with SELECT, UPDATE, and DELETE queries.

Inserting Data

You can use the insert() entry point to insert data.

Consider the following simple typed insert operation:

Mono<Person> insert = template.insert(Person.class)	(1)
		.using(new Person("John", "Doe")); (2)
1 Using Person with the into(…) method sets the INTO table, based on mapping metadata. It also prepares the insert statement to accept Person objects for inserting.
2 Provide a scalar Person object. Alternatively, you can supply a Publisher to run a stream of INSERT statements. This method extracts all non-null values and inserts them.

Updating Data

You can use the update() entry point to update rows. Updating data starts by specifying the table to update by accepting Update specifying assignments. It also accepts Query to create a WHERE clause.

Consider the following simple typed update operation:

Person modified = …

		Mono<Long> update = template.update(Person.class)	(1)
				.inTable("other_table")														(2)
				.matching(query(where("firstname").is("John")))		(3)
				.apply(update("age", 42));												(4)
1 Update Person objects and apply mapping based on mapping metadata.
2 Set a different table name by calling the inTable(…) method.
3 Specify a query that translates into a WHERE clause.
4 Apply the Update object. Set in this case age to 42 and return the number of affected rows.

Deleting Data

You can use the delete() entry point to delete rows. Removing data starts with a specification of the table to delete from and, optionally, accepts a Criteria to create a WHERE clause.

Consider the following simple insert operation:

		Mono<Long> delete = template.delete(Person.class)	(1)
				.from("other_table")															(2)
				.matching(query(where("firstname").is("John")))		(3)
				.all();																						(4)
1 Delete Person objects and apply mapping based on mapping metadata.
2 Set a different table name by calling the from(…) method.
3 Specify a query that translates into a WHERE clause.
4 Apply the delete operation and return the number of affected rows.

Using Repositories, saving an entity can be performed with the ReactiveCrudRepository.save(…) method. If the entity is new, this results in an insert for the entity.

If the entity is not new, it gets updated. Note that whether an instance is new is part of the instance’s state.

This approach has some obvious downsides. If only few of the referenced entities have been actually changed, the deletion and insertion is wasteful. While this process could and probably will be improved, there are certain limitations to what Spring Data R2DBC can offer. It does not know the previous state of an aggregate. So any update process always has to take whatever it finds in the database and make sure it converts it to whatever is the state of the entity passed to the save method.

ID Generation

Spring Data uses the identifer property to identify entities. The ID of an entity must be annotated with Spring Data’s @Id annotation.

When your database has an auto-increment column for the ID column, the generated value gets set in the entity after inserting it into the database.

Spring Data does not attempt to insert values of identifier columns when the entity is new and the identifier value defaults to its initial value. That is 0 for primitive types and null if the identifier property uses a numeric wrapper type such as Long.

Entity State Detection explains in detail the strategies to detect whether an entity is new or whether it is expected to exist in your database.

One important constraint is that, after saving an entity, the entity must not be new anymore. Note that whether an entity is new is part of the entity’s state. With auto-increment columns, this happens automatically, because the ID gets set by Spring Data with the value from the ID column.

Optimistic Locking

Spring Data supports optimistic locking by means of a numeric attribute that is annotated with @Version on the aggregate root. Whenever Spring Data saves an aggregate with such a version attribute two things happen:

  • The update statement for the aggregate root will contain a where clause checking that the version stored in the database is actually unchanged.

  • If this isn’t the case an OptimisticLockingFailureException will be thrown.

Also, the version attribute gets increased both in the entity and in the database so a concurrent action will notice the change and throw an OptimisticLockingFailureException if applicable as described above.

This process also applies to inserting new aggregates, where a null or 0 version indicates a new instance and the increased instance afterwards marks the instance as not new anymore, making this work rather nicely with cases where the id is generated during object construction for example when UUIDs are used.

During deletes the version check also applies but no version is increased.

@Table
class Person {

  @Id Long id;
  String firstname;
  String lastname;
  @Version Long version;
}

R2dbcEntityTemplate template = …;

Mono<Person> daenerys = template.insert(new Person("Daenerys"));                      (1)

Person other = template.select(Person.class)
                 .matching(query(where("id").is(daenerys.getId())))
                 .first().block();                                                    (2)

daenerys.setLastname("Targaryen");
template.update(daenerys);                                                            (3)

template.update(other).subscribe(); // emits OptimisticLockingFailureException        (4)
1 Initially insert row. version is set to 0.
2 Load the just inserted row. version is still 0.
3 Update the row with version = 0.Set the lastname and bump version to 1.
4 Try to update the previously loaded row that still has version = 0.The operation fails with an OptimisticLockingFailureException, as the current version is 1.