Grails GORM Data Services
In this guide, we will learn how to create GORM Data Services in a Grails Application.
Authors: Nirav Assar, Sergio del Amo
Grails Version: 5.0.0
1 Grails Training
Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.
2 Getting Started
In this guide, we are going to do a deep dive into GORM Data Services. Introduced in GORM 6.1, GORM Data Services take the work out of implemented service layer logic by adding the ability to automatically implement abstract classes or interfaces using GORM logic. It results in less code to write, compile time optimization, and ease of testing.
This guide will go into detail on how to create and use GORM Data Services with a sample Grails application. The guide will stay focused on the service layer of the app consistent with persistence.
2.1 What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
2.2 How to complete the guide
To get started do the following:
-
Download and unzip the source
or
-
Clone the Git repository:
git clone https://github.com/grails-guides/grails-gorm-data-services.git
The Grails guides repositories contain two folders:
-
initial
Initial project. Often a simple Grails app with some additional code to give you a head-start. -
complete
A completed example. It is the result of working through the steps presented by the guide and applying those changes to theinitial
folder.
To complete the guide, go to the initial
folder
-
cd
intograils-guides/grails-gorm-data-services/initial
and follow the instructions in the next sections.
You can go right to the completed example if you cd into grails-guides/grails-gorm-data-services/complete
|
3 Writing the Application
We are going to write a simple application with the domains Person
and Address
. These domains have a one-to-many relationship.
We will develop GORM Data Services which perform query and write operations.
3.1 Domain Objects
Create the Person
and Address
domain classes. They have a one-to-many relationship.
package example.grails
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class Person {
String name
Integer age
Set<Address> addresses (1)
static hasMany = [addresses: Address]
}
1 | You normally don’t need to specify default type java.util.Set in a hasMany association. However,
we query the association in a JPA-QL query later in this tutorial and thus we need to be explicit here. |
package example.grails
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class Address {
String streetName
String city
String state
String country
static belongsTo = [person: Person]
}
4 Why GORM Data Services
Automatic implementation of an interface or an abstract class reduces the amount of code written. In
addition, GORM Data Services define transaction boundary semantics automatically. For example, all public methods are
marked with @Transactional
(and read-only for query methods).
Advantages of Data Services
To summarize the advantages:
-
Type Safety - Data service method signatures are compile time checked and compilation will fail if the types of any parameters don’t match up with properties in your domain class.
-
Testing - Since Data Services are interfaces this makes them easy to mock.
-
Performance - The generated services are statically compiled and unlike competing technologies in the Java space no proxies are created so runtime performance doesn’t suffer. Moreover, update operations are performed with efficiency.
-
Correctly define transaction semantics. Users often don’t correctly define transactions semantics. Each method in a Data Service is wrapped in an appropriate transaction (a read-only transaction in the case of read operations).
-
Support Multi-tenancy. In combination with
@CurrentTenant
annotation, GORM data services ease multi-tenancy development.
5 Using GORM Data Services
To write a Data Service, create an interface and annotate it with grails.gorm.services.Service
with the applied domain class.
The @Service
transformation looks at the method signatures of the interface to surmise what implementation should be generated.
It looks at the return type along with the method stem to implement functionality. Check
Data Service Conventions for details.
5.1 Dynamic Finders Like
If you have worked with GORM in the past, it is probable that you have you used Dynamic Finders to query.
A dynamic finder looks like a static method invocation, but the methods themselves don’t actually exist in any form at the code level. Instead, a method is auto-magically generated using code synthesis at runtime, based on the properties of a given class
With GORM Data Services, you can create an interface with methods with the same expressiveness as Dynamic Finders but without the disadvantages. E.g. you get type safety since GORM Data services are statically compiled.
To find a Person
by name with a Dynamic finder you will use
Person.findByName
. Let’s implement a Data Service to achieve the same query.
Create an interface annotated with import grails.gorm.services.Service
and declare a method with the same signature.
package example.grails
import grails.gorm.services.Query
import grails.gorm.services.Service
@Service(Person) (1)
interface PersonDataService {
Person findByName(String name) (1)
void delete(Serializable id) (3)
}
1 | Qualify the @Service with the domain class you want to use. |
2 | In the method findByName , the stem is find which tells GORM it’s a query (thus a read-only transaction will be used), and Name matches the domain property. |
3 | The method delete takes the id of the person which should be removed. It will automatically be wrapped in a writable transaction. |
5.2 Unit Test
You could write a unit test for the previous Data Service as follows:
package example.grails
import grails.gorm.transactions.Transactional
import org.grails.orm.hibernate.HibernateDatastore
import org.springframework.test.annotation.Rollback
import org.springframework.transaction.PlatformTransactionManager
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
@Transactional
class PersonDataServiceWithoutHibernateSpec extends Specification {
@Shared (2)
PersonDataService personDataService
@Shared (2)
@AutoCleanup (3)
HibernateDatastore hibernateDatastore
@Shared (2)
PlatformTransactionManager transactionManager
void setupSpec() {
hibernateDatastore = new HibernateDatastore(Person) (4)
transactionManager = hibernateDatastore.getTransactionManager() (5)
personDataService = this.hibernateDatastore.getService(PersonDataService)
}
@Rollback (6)
void "test find person by name"() {
given:
Person p = new Person(name: "Nirav", age: 39).save()
when:
Person person = personDataService.findByName("Nirav")
then:
person.name == "Nirav"
person.age == 39
}
}
1 | The test should extend spock.lang.Specification |
2 | The Shared annotation is used to indicate to Spock that the HibernateDatastore , personDataService and transactionManager properties are shared across all tests. |
3 | The AutoCleanup annotation makes sure that HibernateDatastore is shutdown when all tests finish executing. |
4 | Within the setupSpec method a new HibernateDatastore is constructed with the domain classes to use as the argument to the constructor. |
5 | In general you have to wrap your test execution logic in a session or transaction. You can obtain the PlatformTransactionManager from the HibernateDatastore. |
6 | Typically you want to annotate your feature methods with the Rollback annotation which is used to rollback any changes made within each test. |
Luckily, Grails Hibernate Plugin includes a utility class grails.test.hibernate.HibernateSpec
which you could extend from and simplify
the previous unit test:
package example.grails
import grails.test.hibernate.HibernateSpec
import spock.lang.Shared
class PersonDataServiceSpec extends HibernateSpec {
@Shared
PersonDataService personDataService
def setup() {
personDataService = hibernateDatastore.getService(PersonDataService)
}
void "test find person by name"() {
given:
new Person(name: "Nirav", age: 39).save(flush: true)
when:
Person person = personDataService.findByName("Nirav")
then:
person.name == "Nirav"
person.age == 39
cleanup:
personDataService.delete(person.id)
}
}
5.3 Property Projection
Add a method to the GORM Data Service which returns a projection. This has the advantage of bringing back only one column instead of the
whole object. There are a few ways to implement projections. One way is to is to use the convention T find[Domain Class][Property]
.
For example, for the property age
of domain class Person
the method will be:
Integer findPersonAge(String name)
Add to the unit test to verify correctness.
void "test find persons age projection"() {
given:
Person person = new Person(name: "Nirav", age: 39).save(flush: true)
when:
Integer age = personDataService.findPersonAge("Nirav")
then:
age == 39
cleanup:
personDataService.delete(person.id)
}
5.4 Save Operation
Data Services can perform write operations as well.
Person save(String name, Integer age)
Add to the unit test to verify correctness.
void "test save person"() {
when:
Person person = personDataService.save("Bob", 22)
then:
person.name == "Bob"
person.age == 22
personDataService.count() == old(personDataService.count()) + 1 (1)
cleanup:
personDataService.delete(person.id)
}
1 | With Spock’s old method we get the value a statement had before the when: block is executed. |
Run all the tests to make sure they pass → gradlew check .
|
5.5 Join Query
Data Services can also implement a join query using the grails.gorm.services.Join
annotation.
Grails associations are lazily loaded by default. For a one to many relationship, loading
of the many side can cause n + 1 problems resulting in
many select statements. This can severely hinder performance.
Essentially we can perform an eager load with @Join
. Apply this to concept to a query on Person
and Address
.
@Join('addresses') (1)
Person findEagerly(String name)
Until this point we have defined the Person GORM Data Service with an interface. You can use an abstract class instead.
A combination of interface and an abstract class implementing the interface with some custom methods is a common pattern. Rewrite
PersonDataService
as shown below:
package example.grails
import grails.gorm.services.Query
import grails.gorm.services.Service
import grails.gorm.services.Join
import grails.gorm.transactions.Transactional
import groovy.util.logging.Slf4j
interface IPersonDataService {
Person findByName(String name)
Integer findPersonAge(String name)
Number count()
@Join('addresses') (1)
Person findEagerly(String name)
void delete(Serializable id)
Person save(Person person)
Person save(String name, Integer age)
}
@Service(Person)
abstract class PersonDataService implements IPersonDataService {
@Transactional
Person saveWithListOfAddressesMap(String name, Integer age, List<Map<String, Object>> addresses) {
saveWithAddresses(name, age, addresses.collect { Map m ->
new Address(streetName: m.streetName as String,
city: m.city as String,
state: m.state as String,
country: m.country as String)
} as List<Address>)
}
@Transactional
Person saveWithAddresses(String name, Integer age, List<Address> addresses) {
Person person = new Person(name: name, age: age)
addresses.each { Address address ->
person.addToAddresses(address)
}
save(person)
}
}
Create an integration test:
package example.grails
import grails.testing.mixin.integration.Integration
import spock.lang.Specification
@Integration (1)
class PersonDataServiceIntSpec extends Specification {
PersonDataService personDataService (2)
void "test join eager load"() {
given:
Person p = personDataService.saveWithListOfAddressesMap('Nirav',39, [
[streetName: "101 Main St", city: "Grapevine", state: "TX", country: "USA"],
[streetName: "2929 Pearl St", city: "Austin", state: "TX", country: "USA"],
[streetName: "21 Sewickly Hills Dr", city: "Sewickley", state: "PA", country: "USA"]
])
when:
Person person = personDataService.findEagerly("Nirav")
then:
person.name == "Nirav"
person.age == 39
when:
List<String> cities = person.addresses*.city
then:
cities.contains("Grapevine")
cities.contains("Austin")
cities.contains("Sewickley")
cleanup:
personDataService.delete(p.id)
}
}
1 | Place your integration tests in src/integration-test and annotate them with grails.testing.mixin.integration.Integration . |
2 | Inject the Data Service. |
In order to observe how Data Service operations translate into SQL statements, we will adjust the logger configuration.
In the logback.groovy
, append the next line line to get a more verbose output of the SQL queries being executed:
logger 'org.hibernate.SQL', TRACE, ['STDOUT'], false
Execute the integration test ./gradlew integrationTest --tests example.grails.PersonDataServiceIntSpec
The previous tests outputted SQL log statement which demonstrates a join has been issued. Below is a sample.
2018-08-20 16:28:40.460 DEBUG --- [ Test worker] org.hibernate.SQL : insert into person (id, version, age, name) values (null, ?, ?, ?)
2018-08-20 16:28:40.472 DEBUG --- [ Test worker] org.hibernate.SQL : insert into address (id, version, person_id, street_name, city, country, state) values (null, ?, ?, ?, ?, ?, ?)
2018-08-20 16:28:40.474 DEBUG --- [ Test worker] org.hibernate.SQL : insert into address (id, version, person_id, street_name, city, country, state) values (null, ?, ?, ?, ?, ?, ?)
2018-08-20 16:28:40.474 DEBUG --- [ Test worker] org.hibernate.SQL : insert into address (id, version, person_id, street_name, city, country, state) values (null, ?, ?, ?, ?, ?, ?)
2018-08-20 16:28:40.533 DEBUG --- [ Test worker] org.hibernate.SQL : select this_.id as id1_1_1_, this_.version as version2_1_1_, this_.age as age3_1_1_, this_.name as name4_1_1_, addresses2_.person_id as person_i3_0_3_, addresses2_.id as id1_0_3_, addresses2_.id as id1_0_0_, addresses2_.version as version2_0_0_, addresses2_.person_id as person_i3_0_0_, addresses2_.street_name as street_n4_0_0_, addresses2_.city as city5_0_0_, addresses2_.country as country6_0_0_, addresses2_.state as state7_0_0_ from person this_ left outer join address addresses2_ on this_.id=addresses2_.person_id where this_.name=?
As you can see only one select query is performed. A query which joins the addresses.
You can see those statements in the tests reports build/reports/tests/classes/example.grails.PersonDataServiceIntSpec.html
standard output.
6 JPA-QL Query
JPA-QL (JPA Query Language) queries query the entity model using a SQL-like text query language. Queries are expressed using entities, properties, and relationship.
GORM Data Service support JPA-QL Queries via the grails.gorm.services.Query
annotation.
JPA-QL queries allows for more complicated queries. Nonetheless, they are still statically compiled and safeguard against SQL injection attacks.
Create a query that searches people by country.
(1)
@Query("""\
select distinct ${p}
from ${Person p} join ${p.addresses} a
where a.country = $country
""") (2)
List<Person> findAllByCountry(String country) (3)
1 | @Query used to define a JPA-QL query. |
2 | You can use multiline strings to define your JPA-QL queries which adds readability. |
3 | country param can be used within the HQL query. |
Create an integration test:
package example.grails
import grails.testing.mixin.integration.Integration
import spock.lang.Specification
@Integration (1)
class PersonDataServiceIntSpec extends Specification {
PersonDataService personDataService (2)
void "test search persons by country"() {
given:
[
[name: 'Nirav', age: 39, addresses: [
[streetName: "101 Main St", city: "Grapevine", state: "TX", country: "USA"],
[streetName: "2929 Pearl St", city: "Austin", state: "TX", country: "USA"],
[streetName: "21 Sewickly Hills Dr", city: "Sewickley", state: "PA", country: "USA"]]],
[name: 'Jeff', age: 50, addresses: [
[streetName: "888 Olive St", city: "St Louis", state: "MO", country: "USA"],
[streetName: "1515 MLK Blvd", city: "Austin", state: "TX", country: "USA"]]],
[name: 'Sergio', age: 35, addresses: [
[streetName: "19001 Calle Mayor", city: "Guadalajara", state: 'Castilla-La Mancha', country: "Spain"]]]
].each { Map<String, Object> m ->
personDataService.saveWithListOfAddressesMap(m.name as String,
m.age as Integer,
m.addresses as List<Map<String, Object>>)
}
when:
List<Person> usaPersons = personDataService.findAllByCountry("USA")
then:
usaPersons
usaPersons.size() == 2
usaPersons.find { it.name == "Nirav"}
usaPersons.find { it.name == "Jeff"}
when:
List<Person> spainPersons = personDataService.findAllByCountry("Spain")
then:
spainPersons.size() == 1
spainPersons.find { it.name == "Sergio"}
}
7 JPA-QL Projection
You can also create a projection to a POGO (Plan Groovy Object) with JPA-QL.
If you have a POGO such as:
package example.grails
import groovy.transform.CompileStatic
import groovy.transform.TupleConstructor
@TupleConstructor (1)
@CompileStatic
class Country {
String name
}
1 | Use Groovy’s TupleConstructor AST Transform to generate a constructor with one parameter. |
Create a GORM Data Service for Address
domain class:
package example.grails
import grails.gorm.services.Query
import grails.gorm.services.Service
@Service(Address)
interface AddressDataService {
@Query("select new example.grails.Country(${a.country}) from ${Address a} group by ${a.country}") (1)
List<Country> findCountries()
}
1 | The constructor generated by TupleConstructor is used in the JPA-QL query. |
You can create a unit test to verify the behaviour:
package example.grails
import grails.test.hibernate.HibernateSpec
import spock.lang.Shared
class AddressDataServiceSpec extends HibernateSpec {
@Shared
AddressDataService addressDataService
@Shared
PersonDataService personDataService
def setup() {
addressDataService = hibernateDatastore.getService(AddressDataService)
personDataService = hibernateDatastore.getService(PersonDataService)
}
def "projections to POGO work"() {
given:
List<Long> personIds = []
[
[name: 'Nirav', age: 39, addresses: [
[streetName: "101 Main St", city: "Grapevine", state: "TX", country: "USA"],
[streetName: "2929 Pearl St", city: "Austin", state: "TX", country: "USA"],
[streetName: "21 Sewickly Hills Dr", city: "Sewickley", state: "PA", country: "USA"]]],
[name: 'Jeff', age: 50, addresses: [
[streetName: "888 Olive St", city: "St Louis", state: "MO", country: "USA"],
[streetName: "1515 MLK Blvd", city: "Austin", state: "TX", country: "USA"]]],
[name: 'Sergio', age: 35, addresses: [
[streetName: "19001 Calle Mayor", city: "Guadalajara", state: 'Castilla-La Mancha', country: "Spain"]]]
].each { Map<String, Object> m ->
personIds << personDataService.saveWithListOfAddressesMap(m.name as String,
m.age as Integer,
m.addresses as List<Map<String, Object>>).id
}
when:
List<Country> countries = addressDataService.findCountries()
then:
countries
countries.size() == 2
countries.any { it.name == 'USA' }
countries.any { it.name == 'Spain' }
cleanup:
personIds.each {
personDataService.delete(it)
}
}
}
8 Conclusion
Allow GORM to implement the methods for you and manage transactions, while you simply define the interface. Data Services can reduce errors, performance issues and are testable. Plus it has a high COOL factor!
Read GORM Data Services documentation to learn more.
Watch Graeme Rocher presentation at Greach Conf 2018: