XNAT data types provide a lot of power for handling data within the application's primary domain of research data. Along with this power, however, comes a fair amount of overhead to create and maintain the data types. For data outside XNAT's core functionality, you can create a Java-based service that provides its own persistent data types and even its own discoverable and documented REST API.
XNAT's data services and REST API use the Spring Framework and Hibernate data persistence framework. The plugin framework provides a number of ways you can integrate your own code into XNAT's own Spring and Hibernate implementations. This documentation focuses primarily on how to accomplish this integration in the XNAT context and not on details of how to write Spring and Hibernate code. Where appropriate, we provide links to sources for more information on particular aspects or features of these frameworks.
This guide describes how to implement your own data types, using the NRG entity service framework to provide basic functionality as well as support the relationship between the actual data type (also referred to as the entity class) and the supporting data access (DAO) and service implementations.
The standard data entity structure in XNAT has three parts:
The entity is the actual package of data you want to save. For example, if you want to be able to store information about different medical imaging scanners, you might have a class named Scanner with properties like name, manufacturer, modality, etc.:
public class Scanner {
private String name;
private String manufacturer;
private String modality;
}
You would also have getter and setter methods for these properties or use Lombok annotations to generate them, but these are omitted here for clarity.
This model provides a lot of power. The idea is that the DAO implements single atomic operations (create an object, get an object), while the service implements larger operations that include one or more of the DAO's lower-level operations. Because the service provides a transactional wrapper for its operations, if something goes wrong with one of the operations in a service transaction, all of the operations can be rolled back. This helps prevent your database ending up in a corrupt or inconsistent state.
The most important aspect of the entity-DAO-service architecture is the division of individual operations at the DAO level and aggregate operations at the service transaction level. By making these aggregate operations transactional, an error in any atomic operation at any point in the aggregate operation can result in the entire operation being rolled back, so that your database is protected from partial operations that can cause issues with data integrity.
The NRG entity service framework can be found in the NRG Framework library. You can look at the source code for this library to better understand how the various parts work together:
$ git clone https://bitbucket.org/xnatdev/framework.git
You can reference this library in your own code by including a reference in build.gradle:
implementation "org.nrg:framework:1.7.6"
Note that the NRG Framework library is referenced by the XDAT core library as well as XNAT Web, so you should automatically have access to NRG Framework through transitive dependencies if you include those libraries in your code as well.
A data entity class should be, as much as possible, nothing but a container for data. It provides no functionality, no operations, no means of transformation. It is simply a noun, a thing, a collection of properties. The NRG entity service framework provides an abstract version of an entity from which full implementations can be created. This abstract class, implemented in org.nrg.framework.orm.hibernate.AbstractHibernateEntity, provides the following properties:
The Scanner data entity has three properties unique to its representation, the scanner name, the manufacturer, and the imaging modality for the scanner. You might also want a unique readable scanner ID to make it easy to identify each instance. In Java, this would look something like this:
@Entity
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = "scannerId"),
@UniqueConstraint(columnNames = {"name", "manufacturer"})})
@Data
@Accessors(prefix = "_")
public class Scanner extends AbstractHibernateEntity {
private String _scannerId;
private String _name;
private String _manufacturer;
private String _modality;
}
This sample uses Lombok annotations to generate the standard get and set methods so you don't need to explicitly define, e.g., getScannerId(). If you'd rather define the methods yourself, just omit the @Data and @Accessors annotations. You can find more information about Lombok on the Project Lombok web site, as well as in lots of tutorials and walkthroughs.
Notice that this class contains no code to manage database transactions, SQL queries, or anything else related to storing an instance of the entity in the database. So how does an instance of this class get persisted? In the context of the entity class, it doesn't really matter. That's the work of the service layer. This class only provides a little bit of information that even indicates that it's supposed to be a data entity:
Everything else about storing and retrieving the data in an instance of an entity class is handled at the repository and service implementations described in the next sections.
The data access object (DAO) or repository (DAO and repository are used basically interchangeably) performs atomic database operations, handles basic framework details like session and state management, and implements specific low-level operations like detailed queries and joins.
Similar to the entity class support, the NRG entity service framework provides a base implementation named org.nrg.framework.orm.hibernate.AbstractHibernateDAO. This includes the following methods (mentions of "ID" here reference the ID value in the AbstractHibernateEntity class, which is a Java long):
Method | Description |
---|---|
create() | Creates a new entry in the database for the submitted data entity instance. |
retrieve() | Retrieves the database entry for the specified ID. |
update() | Updates the entry in the database for the submitted data entity instance. |
saveOrUpdate() | If the instance doesn't have an ID or the ID doesn't exist in the database yet, this creates a new database entry. If the instance ID already exists, this updates the corresponding database entry. |
delete() | Deletes the database entry for the specified ID. |
exists() | Checks whether there's an entry in the database with the specified ID. |
countAll() | Tells you how many entries for the class are stored in the database. |
findByExample() | Returns the entry that matches the example. |
findAll() | Returns a list containing all of the existing entries. |
findAllByExample() | Returns a list containing all of the entries that match the example. |
findByProperties() | Returns a list of entries with property values that match those in the submitted table. |
findByProperty() | Similar to above, but just takes a single property name and value. |
findByUniqueProperty() | Returns an entry that matches a single property name and value. |
By extending this base class in your own repository implementation, you get all of the basic data operations–create, retrieve, update, delete, and a variety of find operations including the ability to find by example and by or more more properties–without writing any more code than just declaring your class, like so:
@Repository
public class ScannerRepository extends AbstractHibernateDAO<Scanner> {
}
The @Repository annotation indicates that this is a data repository. Notice that the Scanner class is referenced here. Adding the extends clause to the Scanner class makes it an entity that the AbstractHibernateDAO implementation knows how to manage.
The service implementation performs business or domain operations, which combine one or more database operations into a single transaction.
The NRG framework provides a base implementation for service classes as well. For various technical reasons, you should divide your service classes into an interface definition and an actual implementation. The interface definition specifies the operations available through the service.
public interface ScannerService extends BaseHibernateService<Scanner> {
Scanner findByScannerId(final String scannerId);
List<Scanner> findByManufacturer(final String manufacturer);
List<Scanner> findByModality(final String modality);
List<Scanner> findByManufacturerAndModality(final String manufacturer, final String modality);
}
Note that this interface extends another interface and that is also configured so that your interface is associated with the Scanner entity. This interface defines some of the basic operations you'd like to be able to perform with this service:
Method | Description |
---|---|
create() | Creates a new entry in the database for the submitted data entity instance. |
retrieve() | Retrieves the database entry for the specified ID. |
update() | Updates the entry in the database for the submitted data entity instance. |
delete() | Deletes the entry in the database for the submitted data entity instance. |
exists() | Checks whether there's an entry in the database with the specified ID. |
getCount() | Tells you how many entries for the class are stored in the database. |
getAll() | Returns a list containing all of the existing entries. |
validate() | Validates that an instance of the entity class conforms to any rules required for the type. |
You might notice that these methods are very similar to the ones on the abstract DAO class. That doesn't mean that implementations extending the abstract service have to be that simple or mirror the repository though, just that the basic operations required from a service are the same as the basic operations found in the DAO class: CRUD and query operations.
The last step is implementing your service class:
@Service
public class HibernateScannerService
extends AbstractHibernateEntityService<Scanner, ScannerRepository>
implements ScannerService {
@Transactional
@Override
public Scanner findByScannerId(final String scannerId) {
return getDao().findByUniqueProperty("scannerId", scannerId);
}
@Transactional
@Override
public List<Scanner> findByManufacturer(final String manufacturer) {
return getDao().findByProperty("manufacturer", manufacturer);
}
@Transactional
@Override
public List<Scanner> findByModality(final String modality) {
return getDao().findByProperty("modality", modality);
}
@Transactional
@Override
public List<Scanner> findByManufacturerAndModality(final String manufacturer, final String modality) {
final Map<String, Object> properties = new HashMap<>();
properties.put("manufacturer", manufacturer);
properties.put("modality", modality);
return getDao().findByProperties(properties);
}
}
Important things to notice about this implementation include: