Simple, maintainable and customizable CRUD UIs with jOOQ and ng-admin

In this blog post I’ll show you a way to build a relatively simple CRUD UI with a Java backend and an AngularJS frontend.

The requirements for the implementation are the following:

  1. The setup should be easy.
  2. It should integrate with jOOQ
  3. It should be Maintainable (the effort of adding, changing and removing entities in the data model should be low).
  4. It should be customizable.

The toolset we will use to fulfill those requirements consists of four important libraries:

  • For a quick server setup we’ll use Spring Boot.
  • To access the database from our application, jOOQ is our friend.
  • AngularJS will be our frontend framework.
  • ng-admin will provide us with a highly configurable CRUD UI.

This blog post is backed by a demo repository where you can find the whole code used in the examples.

Application setup

The application build is configured in the build.gradle file.

For simplicity we use the embedded h2 database and we use flyway to run our database migrations.

Then we use the jOOQ code generator to generate our models and tables from our database schema.

Afterwards we build our Spring Boot application and retrieve a jar file which we can run using our jvm.

The jar is equipped with an embedded tomcat server and we use the spring-boot-starter-web artifact to build our web application.

Database model

The database model of our demo application has a person and a blog post entity.

jOOQ

jOOQ can easily be used with the spring-boot-starter-jooq artifact. It provides the DSLContext to the DI container.

Now we create an abstract class AbstractCRUDRepository to handle some basic database queries.

public abstract class AbstractCRUDRepository<R extends UpdatableRecord, ID, P extends IdentifiableDTO<ID>> {

  protected final DSLContext dslContext;
  private final Table<R> table;
  private final Field<ID> idField;
  private final Class<P> pojoClass;

  public AbstractCRUDRepository(DSLContext dslContext, Table<R> table, Field<ID> idField, Class<P> pojoClass) {
    this.dslContext = dslContext;
    this.table = table;
    this.pojoClass = pojoClass;
    this.idField = idField;
  }

  public List<P> getPage(Integer page, Integer pageSize, String sortField, String sortOrder, Map<String, Object> filterQuery) {
    String sortFieldName = sortField.replaceAll("([A-Z])", "_$1").toLowerCase();
    return dslContext.selectFrom(table)
      .where(filter(filterQuery != null ? filterQuery : new HashMap<>()))
      .orderBy(table.field(sortFieldName).sort(SortOrder.valueOf(sortOrder)))
      .limit(pageSize)
      .offset((page - 1) * pageSize)
      .fetchInto(pojoClass);
  }

  public P getById(ID id) {
    return getRecordById(id).into(pojoClass);
  }

  public P update(ID id, P pojo) {
    R record = getRecordById(id);
    record.from(pojo);
    record.store();
    return record.into(pojoClass);
  }

  // ...

  protected Condition filter(Map<String, Object> filterQuery) {
    return null;
  }

  // ...

}

For each of our domain objects we subclass the abstract repository like this:

@Component
public class PersonRepository extends AbstractCRUDRepository<PersonRecord, Long, PersonDTO> {

  @Autowired
  public PersonRepository(DSLContext dslContext) {
    super(dslContext, PERSON, PERSON.ID, PersonDTO.class);
  }

  @Override
  protected Condition filter(Map<String, Object> filterQuery) {
    if(filterQuery.containsKey("name")) {
      return PERSON.NAME.likeIgnoreCase("%" + filterQuery.getOrDefault("name", "") + "%");
    } else {
      return super.filter(filterQuery);
    }
  }
}

As you can see, we create a filtering mechanism. This will be useful for the autocompletion search in the UI.

Relationships

This approach allows us to manage our entities. But what about the relationships between those entities? Especially the many-to-many relationships contain some logic we don’t want to replicate over and over again.

To face this problem, we create an abstract repository for join table records (already generated from jOOQ).

abstract class AbstractManyToManyRelationRepository<R extends UpdatableRecord> {

  final DSLContext dslContext;
  private final Table<R> table;

  AbstractManyToManyRelationRepository(DSLContext dslContext, Table<R> table) {
    this.dslContext = dslContext;
    this.table = table;
  }

  <T1, T2> List<T2> getRelated(Field<T1> parentIdField, Field<T2> childIdField, T1 id) {
    return dslContext
      .select(childIdField)
      .from(table)
      .where(parentIdField.eq(id))
      .fetch(childIdField);
  }

  // ...

  <T1, T2> void merge(Field<T1> parentField, Field<T2> childField, T1 parentId, List<T2> newChildIds) {
    if(newChildIds != null) {
      List<T2> oldChildIds = getRelated(parentField, childField, parentId);
      oldChildIds.stream()
        .filter(childId -> !newChildIds.contains(childId))
        .forEach(childId -> delete(parentField, childField, parentId, childId));
      newChildIds.stream()
        .filter(childId -> !oldChildIds.contains(childId))
        .forEach(childId -> create(parentField, childField, parentId, childId));
    }
  }

}

Now we can subclass this abstract repository to be able to manage one specific many to many relationships

@Component
public class BlogPostReaderRepository extends AbstractManyToManyRelationRepository<BlogPostReaderRecord> {

  @Autowired
  public BlogPostReaderRepository(DSLContext dslContext) {
    super(dslContext, BLOG_POST_READER);
  }

  public List<Long> getReaders(Long blogPostId) {
    return getRelated(BLOG_POST_READER.BLOG_POST, BLOG_POST_READER.PERSON, blogPostId);
  }

  public void updateReaders(Long blogPostId, List<Long> personIds) {
    merge(BLOG_POST_READER.BLOG_POST, BLOG_POST_READER.PERSON, blogPostId, personIds);
  }

  public void deleteReaders(Long blogPostId) {
    deleteAll(BLOG_POST_READER.BLOG_POST, blogPostId);
  }

}

Going REST

Now that we are equipped with a data access layer we can go and build our REST interface.

Again, we create an abstract class which keeps the common behavior of all the rest controllers.

abstract class AbstractBackofficeController<ID, Resource extends IdentifiableDTO<ID>> {

  final AbstractCRUDRepository<?, ID, Resource> repository;

  AbstractBackofficeController(AbstractCRUDRepository<?, ID, Resource> repository) {
    this.repository = repository;
  }

  protected List<Resource> page(Integer page, Integer pageSize, String sortOrder, String sortField) {
    return repository.getPage(page, pageSize, sortField, sortOrder);
  }

  protected Resource get(ID id) {
    Resource resource = repository.getById(id);
    if (resource == null) {
      throw new ResourceNotFoundException();
    }
    return resource;
  }

  protected ResponseEntity<Void> update(ID id, Resource resource) {
    repository.update(id, resource);
    return buildUpdateResponse();
  }

  // ...

}

At this point you may ask yourself: Why don’t we pull the annotations up into the abstract class to have less code replication?

I actually tried this way in a first run.

But there are two problems with this approach. You might want to have different DTOs for your REST calls. Imagine a user entity with email and password. What do you want to expose to the admin using the interface? Only the email. The other problem is that you don’t want to provide all REST methods for every resource. For example you don’t want to let an admin edit some of your application roles, but he still needs to load them when managing the access rights of a certain user.

So here is a compromise. We subclass the abstract controller again and provide the default REST endpoints. So we can decide for each entity whether we want to provide all CRUD operations. This makes sense, since we want to build a CRUD UI with low effort.

public abstract class AbstractCRUDBackofficeController<ID, Resource extends IdentifiableDTO<ID>> extends AbstractBackofficeController<ID, Resource> {

  AbstractCRUDBackofficeController(AbstractCRUDRepository<?, ID, Resource> repository) {
    super(repository);
  }

  @Override
  @RequestMapping(value = "", method = RequestMethod.GET)
  @ResponseBody
  public List<Resource> page(@RequestParam(value = "_page", defaultValue = "1") Integer page,
                             @RequestParam(value = "_perPage", defaultValue = "30") Integer pageSize,
                             @RequestParam(value = "_sortDir", defaultValue = "DESC") String sortOrder,
                             @RequestParam(value = "_sortField", defaultValue = "id") String sortField,
                             @RequestParam(value = "_filters", required = false) String query) {
    return super.page(page, pageSize, sortOrder, sortField, query);
  }

  @Override
  @RequestMapping(value = "/{id}", method = RequestMethod.GET)
  @ResponseBody
  public Resource get(@PathVariable("id") ID id) {
    return super.get(id);
  }

  @Override
  @RequestMapping(value = "", method = RequestMethod.POST)
  @ResponseBody
  public ResponseEntity<Void> create(@RequestBody Resource dto, @RequestHeader String host) {
    return super.create(dto, host);
  }

  @Override
  @RequestMapping(value = "/{id}", method = RequestMethod.PUT)
  @ResponseBody
  public ResponseEntity<Void> update(@PathVariable ID id, @RequestBody Resource dto) {
    return super.update(id, dto);
  }

  @Override
  @RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
  public ResponseEntity<Void> delete(@PathVariable("id") ID id) {
    return super.delete(id);
  }

}

In our data model we don’t want to hide anything. So we will simply use our AbstractCRUDBackofficeController for the blog posts and persons.

We will manage the readers relation from the blog posts controller, so we have to do some extra work here:

@RestController
@RequestMapping("/crud/blogPosts")
public class BlogPostController extends AbstractCRUDBackofficeController<Long, BlogPostDTO> {

  private final BlogPostReaderRepository blogPostReaderRepository;

  @Autowired
  public BlogPostController(BlogPostRepository blogPostRepository, BlogPostReaderRepository blogPostReaderRepository) {
    super(blogPostRepository);
    this.blogPostReaderRepository = blogPostReaderRepository;
  }

  @Override
  public List<BlogPostDTO> page(@RequestParam(value = "_page", defaultValue = "1") Integer page,
                                @RequestParam(value = "_perPage", defaultValue = "30") Integer pageSize,
                                @RequestParam(value = "_sortDir", defaultValue = "DESC") String sortOrder,
                                @RequestParam(value = "_sortField", defaultValue = "id") String sortField,
                                @RequestParam(value = "_filters", required = false) String query) {
    List<BlogPostDTO> dtos = super.page(page, pageSize, sortOrder, sortField, query);
    dtos.forEach(this::populateReaders);
    return dtos;
  }

  @Override
  public BlogPostDTO get(@PathVariable("id") Long id) {
    return populateReaders(super.get(id));
  }

  @Override
  public ResponseEntity<Void> create(@RequestBody BlogPostDTO dto, @RequestHeader String host) {
    BlogPostDTO persisted = repository.create(dto);
    blogPostReaderRepository.updateReaders(persisted.getId(), dto.getReaders());
    return buildCreateResponse(host, persisted);
  }

  @Override
  public ResponseEntity<Void> update(@PathVariable("id") Long id, @RequestBody BlogPostDTO dto) {
    repository.update(id, dto);
    blogPostReaderRepository.updateReaders(id, dto.getReaders());
    return buildUpdateResponse();
  }

  @Override
  public ResponseEntity<Void> delete(@PathVariable("id") Long id) {
    repository.delete(id);
    blogPostReaderRepository.deleteReaders(id);
    return buildDeleteResponse();
  }

  private BlogPostDTO populateReaders(BlogPostDTO dto) {
    dto.setReaders(blogPostReaderRepository.getReaders(dto.getId()));
    return dto;
  }
}

The UI

We’re done with our backend so far. So let’s have a look at the UI. ng-admin is a highly configurable angular module. Some of it’s most important features are:

  • It talks REST
  • It ships with bootstrap, so the app is fully responsive
  • It provides configurable entity relationships

If you are curious what else ng-admin is capable of, have a look at this demo app which shows a lot of the features of ng-admin.

The integration and configuration is straight forward. Simply depend on the module and then configure your app in a configuration callback:

import personEntity from "entities/person";
import blogPostEntity from "entities/blogPost";

angular.module("app", ["ng-admin"], (NgAdminConfigurationProvider) => {
  var nga = NgAdminConfigurationProvider;
  var admin = nga.application('jOOQ CRUD Demo');
  admin.addEntity(personEntity(nga));
  admin.addEntity(blogPostEntity(nga));
  admin.baseApiUrl("/crud/");
  nga.configure(admin);
});

Your entities can be configured like this:

import {personEntityName} from "./person"

export const blogPostEntityName = "blogPosts";

export default (nga) => {
  const personEntity = nga.entity(personEntityName);
  const entity = nga.entity(blogPostEntityName);
  entity.listView().fields([
    nga.field("title")
      .isDetailLink(true),
    nga.field("author", "reference")
      .targetEntity(personEntity)
      .targetField(nga.field("name")),
    nga.field("readers", "reference_many")
      .targetEntity(personEntity)
      .targetField(nga.field("name"))
      .cssClasses("hidden-xs hidden-sm")
  ]);
  entity.creationView().fields([
    nga.field("title")
      .validation({required: true}),
    nga.field("content", "wysiwyg"),
    nga.field("author", "reference")
      .targetEntity(personEntity)
      .targetField(nga.field("name"))
      .sortField("name")
      .sortDir("DESC")
      .validation({required: true})
      .remoteComplete(true, {
        refreshDelay: 200,
        searchQuery: name => ({name})
      }),
    nga.field("readers", "reference_many")
      .targetEntity(personEntity)
      .targetField(nga.field("name"))
      .sortField("name")
      .sortDir("DESC")
      .remoteComplete(true, {
        refreshDelay: 200,
        searchQuery: name => ({name})
      })
  ]);
  entity.editionView().fields(entity.creationView().fields());
  return entity;
}

As you can see, we need to specify each field for our entities. For the list view as well as for the creation and edition view. The advantage of this approach is that you have full control over the entity representation in the frontend. The drawback is that you have to keep the view in sync with your database model.

There are other approaches to create CRUD UIs like Apache Isis, which implements the Naked Object pattern. This could be appealing to pure Java developers, since you annotate your domain classes. But as soon as you have to customize your CRUD UI or even have to create custom views, I personally think you are better off having full control over the frontend from the beginning.

Apart from that our CRUD application is quite resilient to changes in the data model. The Controller and repositories don’t have to be touched until we rename a table or change a relation.

Conclusion

To see how well we’re doing with this solution, let’s check whether we fulfill the requirements mentioned above.

  1. Easy setup:
    The setup looks not too complex to me.
  2. jOOQ integration:
    We successfully use jOOQ as the only data access library within our CRUD application.
  3. Maintainability:
    This is hard to judge. With some abstraction we built some generic controllers which can be easily extended and reused. The point that prevents me from saying that the application has an excellent maintainability is that we manually have to keep the CRUD UI fields within ng-admin in sync with the data model. But good maintainability is relative. We would have to compare this to other approaches in real-life applications.
  4. Customizability:
    Our app is highly customizable. We can create custom views within the application and provide them to angular-ui-router which is shipped with ng-admin. Also the templates can be overwritten if we need to.

It looks like a viable solution to me. If you like to discuss this solution, feel free to post into the comments below.

Teilen Sie diesen Beitrag

Das könnte dich auch interessieren …

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Durch die weitere Nutzung der Seite stimmen Sie der Verwendung von Cookies zu. Weitere Informationen

Die Cookie-Einstellungen auf dieser Website sind auf "Cookies zulassen" eingestellt, um das beste Surferlebnis zu ermöglichen. Wenn du diese Website ohne Änderung der Cookie-Einstellungen verwendest oder auf "Akzeptieren" klickst, erklärst du sich damit einverstanden.

Schließen