Technology Blogs by SAP
Learn how to extend and personalize SAP applications. Follow the SAP technology blog for insights into SAP BTP, ABAP, SAP Analytics Cloud, SAP HANA, and more.
cancel
Showing results for 
Search instead for 
Did you mean: 
Almost every application deals with two types of localised texts:

  1. Static, pre-defined texts: Texts such as labels in UI, messages etc can be easily handled using Java ResourceBundle where a properties file is generated for each of the language supported by the application.

  2. User-generated data: Texts which end users of the application would generate while using the app and needs to be translated in multiple languages for e.g. name and description fields etc. Handling user-generated translatable texts using resource bundle is not feasible, hence translations should be stored in database.


In this blog post we will see how to handle user-generated localised data in an OData service. We will see how the database tables should be designed, how the entities should be mapped and how the OData requests should be handled.

We will consider a simple example where we need to store a list of Courses offered by an Institution. For each course we need to store ID, Name and Description. Name and Description need to be language dependent.

We will create a spring boot application. We will create entities using JPA and then configure our application to handle OData requests using Olingo.

 

Data Model


 

We would create two tables i.e. Course and CourseLocalized as shown:



Course will store the list of courses being offered. This will have one entry corresponding to each course uniquely identified using CourseID.

CourseLocalized will store different translations of name and description for each course.

Based on above data model we will create two JPA entities, Course and CourseLocalized as follows:

Course entity:


@Entity
@Table(name = "COURSE")
public class Course {
@Id
@Column(name = "COURSEID", length = 10)
private String courseID;

@Column(name = "COURSENAME", length = 50)
private String courseName;

@Column(name = "COURSEDESCRIPTION", length = 250)
private String courseDescription;

@OneToMany(mappedBy = "fkCourse", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
@MapKey(name = "locale")
private Map<String, CourseLocalized> localizations = new HashMap<>();

public Course() {

}
public Course(String courseID) {
this.courseID = courseID;
}
public String getCourseID() {
return courseID;
}
public void setCourseID(String courseID) {
this.courseID = courseID;
}
public String getCourseName() {
// With fallback language as English
if(localizations.get(LocaleContext.getCurrentLocale()) == null) {
if(localizations.get(LocaleContext.getDefaultLocale()) == null) {
return null;
}
else {
return localizations.get(LocaleContext.getDefaultLocale()).getCourseName();
}
}
return localizations.get(LocaleContext.getCurrentLocale()).getCourseName();
}
public void setCourseName(String courseName) {
this.courseName = courseName;
}
public String getCourseDescription() {
// With fallback language as English
if(localizations.get(LocaleContext.getCurrentLocale()) == null) {
if(localizations.get(LocaleContext.getDefaultLocale()) == null) {
return null;
}
else {
return localizations.get(LocaleContext.getDefaultLocale()).getCourseDescription();
}
}
return localizations.get(LocaleContext.getCurrentLocale()).getCourseDescription();
}
public void setCourseDescription(String courseDescription) {
this.courseDescription = courseDescription;
}
public Map<String, CourseLocalized> getLocalizations() {
return localizations;
}
public void setLocalizations(Map<String, CourseLocalized> localizations) {
this.localizations = localizations;
}
}

 

CourseLocalized entity:


@Entity
@Table(name = "COURSELOCALIZED")
public class CourseLocalized {

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID textUUID;

@Column(name = "LOCALE", length = 10)
private String locale;

@Column(name = "COURSENAME", length = 50)
private String courseName;

@Column(name = "COURSEDESCRIPTION", length = 250)
private String courseDescription;

@ManyToOne
@JoinColumn(name = "COURSEID")
private Course fkCourse;

public CourseLocalized() {

}

public CourseLocalized(String locale, String courseName, String courseDescription, Course fkCourse) {
this.locale = locale;
this.courseName = courseName;
this.courseDescription = courseDescription;
this.fkCourse = fkCourse;
}

public CourseLocalized(String locale, Course fkCourse) {
this.locale = locale;
this.fkCourse = fkCourse;
}

// Add getter and setter methods
...
}

There is One-to-Many association between entities Course and CourseLocalized using HashMap of localizations where locale is the key of HashMap and object of CourseLocalized is the value. The HashMap 'localizations' will have all the corresponding translations of an item of Course entity.

CourseLocalized table has CourseID as foreign key specified using @JoinColumn annotation.

Fetch Type of the One-to-Many has been set to 'Eager' so that all the corresponding translations gets fetched when an item of Course entity is read. This is useful when we try to add or update translation of a course in some language. This will be explained in detail later.

 

Handling CRUD operations


 

To handle CRUD operations we would create an interface 'CourseRepository' extending CrudRepository. This automatically creates a bean named 'courseRepository'.

EntityManager can also be used for handling CRUD operations. But the benefit of using CrudRepository is that it takes care of opening and closing transactions which entity manager doesn't.
public interface CourseRepository extends CrudRepository<Course, String>{

}

 

Implementing OData


 

To enable our application to handle OData requests we need to register OData servlet specifying the URL mapping and passing the factory implementation as an init parameter to the servlet. We will implement CustomODataFactory extending ODataJPAServiceFactory and override the method initializeODataJPAContext and set the language passed as header parameter(ACCEPT_LANGUAGE) in a thread local class LocaleContext which can be used to get the current language wherever we need. And finally we would create a custom OData processor overriding the createEntity and updateEntity methods to handle create and update of course along with translations in multiple languages.

OData servlet registration:


@Configuration
public class ODataServletRegistration {

@Bean
ServletRegistrationBean<CXFNonSpringJaxrsServlet> odataBean() {
ServletRegistrationBean<CXFNonSpringJaxrsServlet> odataServlet = new ServletRegistrationBean<CXFNonSpringJaxrsServlet>(new CXFNonSpringJaxrsServlet(),
"/course.srv/*");
Map<String, String> initParameters = new HashMap<String, String>();
initParameters.put("javax.ws.rs.Application", "org.apache.olingo.odata2.core.rest.app.ODataApplication");
initParameters.put("org.apache.olingo.odata2.service.factory", "com.example.course_localised.config.CustomODataFactory");
odataServlet.setInitParameters(initParameters);

return odataServlet;
}
}

 

Custom OData factory implementation:


public class CustomODataFactory extends ODataJPAServiceFactory {

public static final String ENTITY_MANAGER_FACTORY_ID = "entityManagerFactory";
private EntityManagerFactory factory;

@Override
public ODataJPAContext initializeODataJPAContext() throws ODataJPARuntimeException {
ODataJPAContext oDataJPAContext = this.getODataJPAContext();

// Here we have considered that only one language will be sent in parameter ACCEPT_LANGUAGE
// But actually there can be multiple languages for example: fr-CH, fr;q=0.9, en;q=0.8
// Here q specifies the weight of acceptable languages. By default the weght is 1.
// i.e. fr-CH has weight 1 and is the most preferred language for the request sent, followed by fr with weight 0.9, followed by en with weight 0.8
// To handle multiple acceptable languages, have a List in LocaleContext and fill the list with acceptable languages
// ODataJPAContext.getODataContext().getAcceptableLanguages() returns the list of acceptable languages in decreasing order of weights

// Setting current language to ACCEPT-LANGUAGE provided in OData request
String locale = oDataJPAContext.getODataContext().getRequestHeader(HttpHeaders.ACCEPT_LANGUAGE);
LocaleContext.setCurrentLocale(locale);

System.out.println("OdataJPAContext started");

try {

factory = (EntityManagerFactory) SpringContextsUtil.getBean(ENTITY_MANAGER_FACTORY_ID);
oDataJPAContext.setEntityManagerFactory(factory);
oDataJPAContext.setPersistenceUnitName("default");

return oDataJPAContext;

} catch (Exception e) {

throw new RuntimeException(e);

}

}

@Override
public ODataSingleProcessor createCustomODataProcessor(ODataJPAContext oDataJPAContext) {
return new CustomODataJPAProcessor(oDataJPAContext);
}
}

This would transform Persistent model into OData service generating OData entities and enable our application to handle OData requests.

Here we have overwritten the method createCustomODataProcessor to create a custom OData processor which would enable us to overwrite methods with handles CRUD operations on OData entities.

 

Custom OData processor:


Here we will override the method createEntity and updateEntity. Overridden createEntity method will handle creation of an entry in Course entity as well as saving the name and description for the created course in the specified language where as updateEntity will handle saving name and description of course in a new language or update name and description in an existing language.
public class CustomODataJPAProcessor extends ODataJPADefaultProcessor {

public static final String ENTITY_MANAGER_FACTORY_ID = "entityManagerFactory";
public static final String COURSE_REPOSITOTY_BEAN_NAME = "courseRepository";

private CourseRepository courseRepository;

private static final String COURSE_ENTITY_SET = "Courses";

public CustomODataJPAProcessor(ODataJPAContext oDataJPAContext) {
super(oDataJPAContext);
}

// Create a new entry in Course entity and save the Course Name and Course Language for the specified language

@Override
public ODataResponse createEntity(PostUriInfo uriInfo, InputStream content, String requestContentType,
String contentType) throws ODataException {
// Get target entity set on which operation is to be performed
String targetEntitySet = uriInfo.getTargetEntitySet().getName();

if (uriInfo.getTargetEntitySet().getEntityType().hasStream()) {
throw new ODataNotImplementedException();
}

// Fetch body from OData request
EntityProviderReadProperties properties = EntityProviderReadProperties.init().mergeSemantic(false).build();

ODataEntry entry = EntityProvider.readEntry(requestContentType, uriInfo.getTargetEntitySet(), content,
properties);

Map<String, Object> data = entry.getProperties();

// Get current language for further processing
String currentLocale = LocaleContext.getCurrentLocale();

if (targetEntitySet.equals(COURSE_ENTITY_SET)) {

System.out.println("In createEntity of " + COURSE_ENTITY_SET);

// Get course repository bean since Autowiring will not work here
// as object of CustomODataJPAProcessor has been created using new operator in createCustomODataProcessor method of CustomODataFactory
courseRepository = (CourseRepository) SpringContextsUtil.getBean(COURSE_REPOSITOTY_BEAN_NAME);

// Get values of properties provided in body
String courseID = (String) data.get("CourseID");
String courseName = (String) data.get("CourseName");
String courseDescription = (String) data.get("CourseDescription");

// Create objects of Course and CourseLocalized and put the CourseLocalized object into localizations map
// And then call save of CourseRepository passing Course object. This would save Course as well as CourseLocalized
Course course = new Course(courseID);

CourseLocalized courseLocalized = new CourseLocalized(currentLocale, courseName, courseDescription, course);

course.getLocalizations().put(currentLocale, courseLocalized);

courseRepository.save(course);

return EntityProvider.writeEntry(contentType, uriInfo.getTargetEntitySet(), entry.getProperties(),
EntityProviderWriteProperties.serviceRoot(getContext().getPathInfo().getServiceRoot()).build());

} else {
return super.createEntity(uriInfo, content, requestContentType, contentType);
}
}


// Saving Course Name and Description in a new language for an existing course will be handled using PUT request i.e. update of course entity
@Override
public ODataResponse updateEntity(PutMergePatchUriInfo uriInfo, InputStream content, String requestContentType,
boolean merge, String contentType) throws ODataException {
// Get target entity set on which operation is to be performed
String targetEntitySet = uriInfo.getTargetEntitySet().getName();

// Fetch body from OData request
EntityProviderReadProperties properties = EntityProviderReadProperties.init().mergeSemantic(false).build();

ODataEntry entry = EntityProvider.readEntry(requestContentType, uriInfo.getTargetEntitySet(), content,
properties);

Map<String, Object> data = entry.getProperties();

// Get current language for further processing
String currentLocale = LocaleContext.getCurrentLocale();

if(targetEntitySet.equals(COURSE_ENTITY_SET)) {
System.out.println("In updateEntity of " + COURSE_ENTITY_SET);

// Get courseID to be processed from key predicates
String courseID = uriInfo.getKeyPredicates().get(0).getLiteral();

// Get course repository bean
courseRepository = (CourseRepository) SpringContextsUtil.getBean(COURSE_REPOSITOTY_BEAN_NAME);

// Get the course object for the courseID passed as key predicate
Optional<Course> courseObj = courseRepository.findById(courseID);

if (courseObj.isPresent()) {
// Get Course from Optional<Course>
Course course = courseObj.get();
CourseLocalized courseLocalized = new CourseLocalized();


if(course.getLocalizations().get(currentLocale) != null) {
courseLocalized = course.getLocalizations().get(currentLocale);
}
else {
courseLocalized.setLocale(currentLocale);
courseLocalized.setFkCourse(course);
}


if (data.get("CourseName") != null) {
String courseName = (String) data.get("CourseName");
courseLocalized.setCourseName(courseName);
}

if(data.get("CourseDescription") != null) {
String courseDescription = (String) data.get("CourseDescription");
courseLocalized.setCourseDescription(courseDescription);
}

course.getLocalizations().put(currentLocale, courseLocalized);

// Save the course object using course repository.
// Both create and update is handled using the save method of repository
courseRepository.save(course);

return ODataResponse.status(HttpStatusCodes.NO_CONTENT).build();
}

else {
// No entry exists for the given courseID
return ODataResponse.status(HttpStatusCodes.NOT_FOUND).build();
}
}
else {
return super.updateEntity(uriInfo, content, requestContentType, merge, contentType);
}


}
}

To save an item of Course along with the name and description, we need to do the following:

  1. Create an object of Course passing the Course ID i.e.
    Course course = new Course(courseID);

  2. Create an object of CourseLocalized passing language, name, description and course object created in first step
    CourseLocalized courseLocalized = new CourseLocalized(currentLocale, courseName, courseDescription, course);

  3. Add the courseLocalized object to the localizations map of the course
    course.getLocalizations().put(currentLocale, courseLocalized);

  4. Call the save method of the CourseRepository passing the course object
    courseRepository.save(course);


Saving the course object, creates entry in Course table as well as in CourseLocalized table. This way object as well as text can be saved simultaneously without the need to save them using separate method calls.

Similarly, to update text in some language or save text in some new language we can use the updateEntity method(which gets triggered on PUT request on entity), we would create the two objects of Course and CourseLocalized and then add or update the localizations map of course object and then save the course object. In order to avoid the overwriting of texts with null values, get the courseLocalized object from the localizations, if it already exists. See the above code sample for reference.

Use of Fetch Type as Eager:


Now let me explain the reason why we used Fetch Type as 'Eager' while setting up the One-to-Many association between Course and CourseLocalized entities.

Suppose we have saved an item in Course and saved the name and description in English and German languages. Now we want to save in French as well. We would trigger a PUT request on Course and in updateEntity, we would create the courseLocalized object of French and add it to the localizations map of course and call save of course. This would delete the entries corresponding to the English and German languages, overwriting this with French language.

In order to avoid this, before calling the save of course we need to make sure that the localizations map contains entries corresponding to previously saved languages as well. Setting fetch type as Eager rescues us from this and hence is beneficial while updating the texts in some language or saving texts in a new language.

Test Samples:



  1. POST request to create a new Course in English language:
    Language should be passed as header parameter in Postman e.g. Accept-Language -> en

  2. PUT request to save name and description in German(Accept-Language -> de)


In tables, the data is saved as follows:

Course table:



CourseLocalized table:




 

We saw how easy it was to handle user-generated language dependent data using JPA and finally the integration with Olingo to create OData service was easy to implement. The use of HashMap and CrudRepository came to our rescue and helped us easily handle the CRUD operations and allowed us to create as well as update the Course details along with name and description, that too in a single method call. The generated OData service can now be used to create localized applications which supports multiple languages.

Hope this blog post was helpful for you. For the code behind this post, refer the Git URL: https://github.wdf.sap.corp/I331559/handling-localized-data
3 Comments