Monday, March 2, 2026

OmniPersistence and OptimusFaces finally reach 1.0

After roughly ten years of 0.x releases, both OmniPersistence and OptimusFaces have finally reached 1.0. Both have been in active use in several production apps since 2015 (primarily with Hibernate). This post gives an refreshing overview of what they do and what went into the 1.0 releases.

OmniPersistence 1.0

OmniPersistence reduces boilerplate in the Jakarta Persistence layer. It provides a rich base service class, declarative soft-delete, field-level auditing, structured pagination with typed search criteria, and provider and database detection. It works with Hibernate, EclipseLink and OpenJPA on any Jakarta EE 10 runtime.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnipersistence</artifactId>
    <version>1.0</version>
</dependency>

It requires a minimum of Java 17 and Jakarta EE 10.

Entity model hierarchy

OmniPersistence ships with six base entity classes. Just pick the one that covers what your entity needs:

All four identity methods equals, hashCode, compareTo and toString default to the database ID. In case you want to base them on a business key instead, simply override identityGetters() once and all four are consistent:

@Entity
public class Phone extends GeneratedIdEntity<Long> {

    private Type type;
    private String number;
    private Person owner;

    @Override
    protected Stream<Function<Phone, Object>> identityGetters() {
        return Stream.of(Phone::getType, Phone::getNumber);
    }
}

BaseEntityService

Extend BaseEntityService<I, E> to get a full CRUD service for your entity. It works as an EJB or a CDI bean:

@ApplicationScoped // Or @Stateless if you're still on EJB
public class PersonService extends BaseEntityService<Long, Person> {
    // The @PersistenceContext is already injected. Nothing else needed.
}

Note: if you're still on EJBs, it might be wise to migrate to CDI, as development on the EJB spec has basically stalled and EJB is in long term going to be discommended in favor of CDI.

The inherited BaseEntityService API offers a lot of helpful shortcut methods for lookups, CRUD and JPQL:

// READ
Optional<Person> found = personService.findById(id);
Person person = personService.getById(id); // or null
List<Person> all = personService.list();
List<Person> subset = personService.list("WHERE e.verified = true");

// WRITE
Long id = personService.persist(newPerson);
Person updated = personService.update(person);
Person saved = personService.save(person); // persist-or-update
personService.delete(person);

Short JPQL fragments are auto-expanded to SELECT e FROM EntityName e <fragment>, so the alias e is always predefined.

Pagination with Page and search criteria

Page is an immutable value object that bundles offset, limit, ordering and search criteria. Pass it to getPage() and get back a PartialResultList that also carries the total count when requested:

Map<String, Object> criteria = new LinkedHashMap<>();
criteria.put("lastName", Like.contains("smith"));
criteria.put("age", Between.range(18, 65));
criteria.put("status", Not.value("BANNED"));
criteria.put("role", Role.ADMIN);

Page page = Page.with()
    .range(0, 10)
    .orderBy("lastName", true)
    .allMatch(criteria)
    .build();

PartialResultList<Person> result = personService.getPage(page, true); // true = include count as well
List<Person> list = result;
int count = result.getEstimatedTotalNumberOfResults(); // or -1 if count was not included

The available search criteria wrappers are:

  • Like - case-insensitive pattern matching (startsWith, endsWith, contains)
  • Between - range queries
  • Order - comparison operators (<, >, <=, >=)
  • Not - negation wrapper around any value or criteria
  • Bool, Numeric, Enumerated, IgnoreCase - specialised type handling
  • A plain value produces an exact equality predicate

Conditions can be grouped with allMatch() (AND) or anyMatch() (OR) and mixed freely. In case you need stable performance on large datasets without SQL OFFSET, cursor-based (keyset) pagination is also available.

Soft delete and auditing

Mark a boolean field with @SoftDeletable and BaseEntityService will automatically exclude soft-deleted rows from all reads and expose dedicated softDelete, softUndelete and listSoftDeleted methods:

@Entity
public class Comment extends GeneratedIdEntity<Long> {

    private String text;

    @SoftDeletable
    private boolean deleted;
}
commentService.softDelete(comment);
commentService.softUndelete(comment);
List<Comment> gone = commentService.listSoftDeleted();

Field-level change auditing fires a CDI event for every @Audit-annotated field that is modified during a transaction:

@Entity
@EntityListeners(AuditListener.class)
public class Config extends GeneratedIdEntity<Long> {

    private String key;

    @Audit
    private String value;
}
public void onAuditedChange(@Observes AuditedChange change) {
    log.info("{}.{}: {} -> {}",
        change.getEntityName(), change.getPropertyName(),
        change.getOldValue(), change.getNewValue());
}

Provider and database detection

OmniPersistence detects the active Jakarta Persistence provider and the underlying database at startup and makes them available via getProvider() and getDatabase() on BaseEntityService. This is used internally to emit provider-correct SQL, for example because string casting syntax differs across Hibernate, EclipseLink and OpenJPA. In case you need it in your own service code, you can simply call them directly:

public void someMethod() {
    if (getProvider() == Provider.HIBERNATE) {
        // ...
    }

    if (getDatabase() == Database.POSTGRESQL) {
        // ...
    }
}

Supported databases as of 1.0: H2, MySQL, PostgreSQL, SQL Server and DB2.

What changed for 1.0

The most impactful change is the decoupling from EJB. The initial 0.x versions required the service class to be a @Stateless EJB. It wasn't possible to make them an @ApplicationScoped CDI bean because some internals related to auditing (preupdate/postupdate) which rely on presence of EJB's SessionContext would break. These internals have been improved in OmniPersistence 1.0 and you can finally freely choose between EJB or CDI.

The most visible API change is that Consumer<Map<String, Object>> parameters were replaced by direct Map<String, Object> parameters. The indirection was introduced in Java 8 to avoid the verbosity of new HashMap() at every call site. With Java 9's Map.of(), there is no longer a reason for it.

Other notable changes: explicit SQL Server and DB2 support was added to Database enum, compatibility with current versions of Hibernate, EclipseLink and OpenJPA was improved (in particular around @OneToMany and @ElementCollection pagination and filtering), and a comprehensive set of missing Javadocs and unit/integration tests was filled in.

OptimusFaces 1.0

OptimusFaces combines OmniFaces and PrimeFaces with help of OmniPersistence. The goal is to make it a breeze to create lazy-loaded, searchable, sortable and filterable <p:dataTable> based on a Jakarta Persistence model and a generic entity service.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>optimusfaces</artifactId>
    <version>1.0</version>
</dependency>

Requires a minimum of Java 17, Jakarta EE 10 Web Profile, OmniFaces 4.0, PrimeFaces 15.0.0 and OmniPersistence 1.0.

PagedDataModel

PagedDataModel is the backing model for <op:dataTable>. It extends from PrimeFaces LazyDataModel. You create one in a CDI bean via the fluent builder:

@Named
@ViewScoped
public class Persons implements Serializable {

    private PagedDataModel<Person> model;

    @Inject
    private PersonService personService;

    @PostConstruct
    public void init() {
        model = PagedDataModel.lazy(personService)
            .criteria(this::getCriteria)
            .build();
    }

    private Map<Getter<Person>, Object> getCriteria() {
        Map<Getter<Person>, Object> criteria = new LinkedHashMap<>();
        criteria.put(Person::isActive, true);
        return criteria;
    }

    public PagedDataModel<Person> getModel() {
        return model;
    }
}

The criteria supplier is re-evaluated on every table interaction, so backing bean fields can drive it dynamically.

private Map<Getter<Person>, Object> getCriteria() {
    Map<Getter<Person>, Object> criteria = new LinkedHashMap<>();

    if (searchName != null && !searchName.isBlank()) {
        criteria.put(Person::getName, Like.startsWith(searchName));
    }

    if (searchStatus != null) {
        criteria.put(Person::getStatus, searchStatus);
    }

    if (searchCreatedAfter != null) {
        criteria.put(Person::getCreated, Order.greaterThanOrEqualTo(searchCreatedAfter));
    }

    return criteria;
}

In case you already have a small list in memory, PagedDataModel.nonLazy(list) will apply sorting and filtering in-memory instead.

op:dataTable and op:column

Wire the model to the view with <op:dataTable> and declare columns with <op:column field="...">. Column headers, sorting and filtering are all derived from the field name automatically:

<op:dataTable id="persons" value="#{persons.model}"
    searchable="true" exportable="true" selectable="true">

    <op:column field="firstName" />
    <op:column field="lastName" />
    <op:column field="dateOfBirth">
        <f:convertDateTime type="localDate" pattern="yyyy-MM-dd" />
    </op:column>
    <op:column field="address.city" />
    <op:column field="phones.number" />
    <op:column field="groups" filterMode="contains" />

</op:dataTable>

searchable="true" adds a global filter bar above the table. exportable="true" adds a column toggler and a CSV/PDF/XLSX export button. selectable="true" adds checkboxes and selected rows are available as model.getSelection().

The field attribute on <op:column> understands dot-notation across Jakarta Persistence relationship types. Thanks to the NestedBaseEntityELResolver address.city navigates a @ManyToOne, phones.number renders each element of a @OneToMany collection on a separate line, and groups on an @ElementCollection renders each element inline. Sorting and filtering on database side works for @ManyToOne and @OneToOne. For collections it depends on the Jakarta Persistence provider.

In case the generated cell output is not sufficient, you can fully customise it:

<op:column field="lastName">
    <ui:define name="cell">
        <h:link value="#{item.firstName} #{item.lastName}" outcome="person">
            <f:param name="id" value="#{item.id}" />
        </h:link>
    </ui:define>
</op:column>

Bookmarkability

Table state (current page, sort column, sort direction, active filters) is automatically reflected in the browser URL as query parameters with help of optimusfaces.js. This makes table state bookmarkable and shareable with no extra work. Parameter names are derived from the table id. In case you have multiple tables on the same view, you can set a queryParameterPrefix on each to keep them separate.

Ajax event marker classes

OptimusFaces uses predefined PFS (PrimeFaces Selectors) classes that components can use to opt in to automatic updates when specific table events fire:

  • updateOnDataTablePage - updated on every pagination change
  • updateOnDataTableSort - updated on sort
  • updateOnDataTableFilter - updated when the filter or global search changes
  • updateOnDataTableSelect - updated on row selection
<p:outputPanel styleClass="updateOnDataTableFilter">
    Found #{persons.model.rowCount} persons
</p:outputPanel>

What changed for 1.0

Sort and filter metadata are now cached inside LazyPagedDataModel to avoid repeated map lookups on every render. SQL Server and DB2 were added to the integration test matrix. A TomEE 10.1.x/OpenWebBeans issue with <c:set scope="application"> inside column.xhtml (which caused a LinkageError on the second column inclusion due to a duplicate CDI proxy class definition) was fixed by removing the explicit scope from those tags. OpenJPA's pagination misbehaviour for @OneToMany fetch joins is now worked around with a postponed-fetch strategy that issues a secondary WHERE id IN (...) query instead of relying on a single JOIN FETCH that OpenJPA would incorrectly LIMIT before aggregating rows per entity.

Testing matrix

OptimusFaces runs its integration tests against three Jakarta Persistence providers:

  • Hibernate 7 as provided by WildFly
  • EclipseLink 5 as provided by GlassFish
  • OpenJPA 4 as provided by TomEE

And five databases:

  • H2 (embedded)
  • MySQL 8
  • PostgreSQL 15
  • SQL Server 2022
  • DB2 12

Not every combination of provider and database is exercised, but the 15-environment matrix is wide enough to shake out a number of interesting provider-specific and database-specific bugs that would otherwise only surface in production.

A word on AI assistance

Getting both libraries to finally make it to 1.0 involved a lot of work that had been pending for a long time: missing Javadocs across both codebases, additional unit and integration tests, hardening against current versions of Hibernate, EclipseLink and OpenJPA, and fixing a number of subtle EclipseLink/OpenJPA-specific bugs around @OneToMany pagination, @ElementCollection filtering and nested correlated subqueries. I simply didn't want to release a 1.0 with halfbaked documentation or subtle EclipseLink/OpenJPA-specific bugs but never really got the time to get there. A substantial part of that work was finally done with help of Claude Code. AI-assisted development has come far enough to be genuinely useful for this kind of systematic, high-context work: completing patterns across many files, catching edge cases in tricky SQL generation code, and writing correct Javadoc for APIs as never seen before.

Links

No comments: