How to implement a custom String-based sequence identifier generator with Hibernate

Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?

Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.

So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!

Introduction

One of my blog readers bumped into the assigned generator with a sequence or an identity column post and wondered if it was possible to generate String-based identifiers instead.

I accepted the challenge and answered his question on StackOverflow. However, this post is going to explain this topic in greater detail, so there we go.

The custom identifier generator

We need a Hibernate identifier generator that can take any value that we manually assign, and it can also automatically generate a unique identifier when the entity identifier is null. However, the user does not want to use a UUID-like random identifier. Instead, the user needs to generate a String value that combines a prefix and a numerical value that is obtained from a database sequence.

Our custom identifier generator looks like this:

public class StringSequenceIdentifier implements 
        IdentifierGenerator, Configurable {

    public static final String SEQUENCE_PREFIX = "sequence_prefix";

    private String sequencePrefix;

    private String sequenceCallSyntax;

    @Override
    public void configure(
            Type type,
            Properties params,
            ServiceRegistry serviceRegistry)
        throws MappingException {

        final JdbcEnvironment jdbcEnvironment = serviceRegistry
        .getService(
            JdbcEnvironment.class
        );

        final Dialect dialect = jdbcEnvironment.getDialect();

        final ConfigurationService configurationService = serviceRegistry
        .getService(
            ConfigurationService.class
        );

        String globalEntityIdentifierPrefix = configurationService
        .getSetting(
            "entity.identifier.prefix",
            String.class,
            "SEQ_"
        );

        sequencePrefix = ConfigurationHelper
        .getString(
            SEQUENCE_PREFIX,
            params,
            globalEntityIdentifierPrefix
        );

        final String sequencePerEntitySuffix = ConfigurationHelper
        .getString(
            SequenceStyleGenerator.CONFIG_SEQUENCE_PER_ENTITY_SUFFIX,
            params,
            SequenceStyleGenerator.DEF_SEQUENCE_SUFFIX
        );

        boolean preferSequencePerEntity = ConfigurationHelper
        .getBoolean(
            SequenceStyleGenerator.CONFIG_PREFER_SEQUENCE_PER_ENTITY,
            params,
            false
        );

        final String defaultSequenceName = preferSequencePerEntity
            ? params.getProperty(JPA_ENTITY_NAME) + sequencePerEntitySuffix
            : SequenceStyleGenerator.DEF_SEQUENCE_NAME;

        sequenceCallSyntax = dialect
        .getSequenceNextValString(
            ConfigurationHelper.getString(
                SequenceStyleGenerator.SEQUENCE_PARAM,
                params,
                defaultSequenceName
            )
        );
    }

    @Override
    public Serializable generate(
            SharedSessionContractImplementor session, 
            Object obj) {
            
        if (obj instanceof Identifiable) {
            Identifiable identifiable = (Identifiable) obj;
            Serializable id = identifiable.getId();

            if (id != null) {
                return id;
            }
        }

        long seqValue = ((Number)
            Session.class.cast(session)
            .createNativeQuery(sequenceCallSyntax)
            .uniqueResult()
        ).longValue();

        return sequencePrefix + String.format("%011d%s", 0 ,seqValue);
    }
}

The sequenceCallSyntax holds the underlying database-specific way of calling a sequence. When the generate method is called, we only generate an identifier if the user hasn’t provided a non-nullable value. To construct the String-based identifier, we fetch a new sequence value from the database and concatenate it with the given prefix.

Both the database sequence name and the prefix are configurable, as demonstrated by the entity mapping:

@Entity(name = "Post") 
@Table(name = "post")
public class Post implements Identifiable<String> {

    @Id
    @GenericGenerator(
        name = "assigned-sequence",
        strategy = "com.vladmihalcea.book.hpjp.hibernate.identifier.StringSequenceIdentifier",
        parameters = {
            @org.hibernate.annotations.Parameter(
                name = "sequence_name", value = "hibernate_sequence"),
            @org.hibernate.annotations.Parameter(
                name = "sequence_prefix", value = "CTC_"),
        }
    )
    @GeneratedValue(
        generator = "assigned-sequence", 
        strategy = GenerationType.SEQUENCE)
    private String id;

    @Version
    private Integer version;

    public Post() {
    }

    public Post(String id) {
        this.id = id;
    }

    @Override
    public String getId() {
        return id;
    }
}

Testing time

With this mapping in place, it’s time to persist several entities and see what identifiers are saved in the database:

doInJPA(entityManager -> {
    entityManager.persist(new Post());
    entityManager.persist(new Post("ABC"));
    entityManager.persist(new Post());
    entityManager.persist(new Post("DEF"));
    entityManager.persist(new Post());
    entityManager.persist(new Post());
});

When running the test case above on PostgreSQL, Hibernate generates the following statements:

SELECT nextval ('hibernate_sequence')
SELECT nextval ('hibernate_sequence')
SELECT nextval ('hibernate_sequence')
SELECT nextval ('hibernate_sequence')

INSERT INTO post (version, id) VALUES (0, 'CTC_000000000001')
INSERT INTO post (version, id) VALUES (0, 'ABC')             
INSERT INTO post (version, id) VALUES (0, 'CTC_000000000002')
INSERT INTO post (version, id) VALUES (0, 'DEF')             
INSERT INTO post (version, id) VALUES (0, 'CTC_000000000003')
INSERT INTO post (version, id) VALUES (0, 'CTC_000000000004')

When running the same test case on Oracle, the sequence syntax changes appropriately:

SELECT hibernate_sequence.nextval FROM dual
SELECT hibernate_sequence.nextval FROM dual
SELECT hibernate_sequence.nextval FROM dual
SELECT hibernate_sequence.nextval FROM dual

INSERT INTO post (version, id) VALUES (0, 'CTC_000000000001')
INSERT INTO post (version, id) VALUES (0, 'ABC')             
INSERT INTO post (version, id) VALUES (0, 'CTC_000000000002')
INSERT INTO post (version, id) VALUES (0, 'DEF')             
INSERT INTO post (version, id) VALUES (0, 'CTC_000000000003')
INSERT INTO post (version, id) VALUES (0, 'CTC_000000000004')

I'm running an online workshop on the 20-21 and 23-24 of November about High-Performance Java Persistence.

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.

Conclusion

Hibernate is highly extendable, and the identifier generators are no different. From custom types to identifier generators, @Formula, @Where, or @Any mappings, Hibernate allows you to tackle any data binding requirement you may have.

Code available on GitHub.

Transactions and Concurrency Control eBook

4 Comments on “How to implement a custom String-based sequence identifier generator with Hibernate

  1. What I have found surprising here is that, the generated value when using the @GeneratorType annotation together with a custom generator, is not available until the transaction is committed or the change is flushed.

    The returning entity after calling repository.save() will not have the logicalId field initialized, so I had to use saveAndFlush() as I needed to do something else with the entity before my transactional method ended.

    That is to be expected when using @Generated (as in this case the value is DB-side generated), but I would NOT have expected that from @GeneratorType, as the value is service-side generated.

    For example, when using the common @Id+ @GeneratedValue + @GenericGenerator, the generated value is already initialized in the returning entity from a save() method, not needed to flush there.

  2. I found this post very useful, as we have implemented some custom generators following the advices learnt here.

    So far they were applied fine to our primary keys (@Id annotated fields in our entities).

    But now we wanted to apply one of our custom generators to an entity field which is not a primary key. It appears so that @GeneratedValue and @GenericGenerator will not work without @Id (and even though it was requested time ago, still there is no answer to that: https://github.com/jakartaee/persistence/issues/113).

    I was wondering is there is some workaround to this, or else we would need to manually set the value for such fields, I mean not being able to take advantage of the annotation,

    • Depending on the Hibernate version you are using, the solution will differ.

      On Hibernate 6, there’s a new Generator abstraction that removes the previous limitations that you could only use for identifiers only.

      On Hibernate 5, you could use something like the @Generated annotation, as explained in this article.

      • Yes, I also knew about that annotation, but the problem is that @Generated only works with DB side generated values, whereas in my case I still want to use my custom generator in service side.

        In the end, I came out with this solution:

        @GeneratorType(type = LogicalIdGenerator.class, when = INSERT)
        @Column(name = "LOGICAL_ID", updatable = false, nullable = false, length = 26)
        protected String logicalId;

        Which allows me to use our custom generator (only that this time is a ValueGenerator, instead of an IdentifierGenerator).

        It should trigger only on insert time, but anyway I implemented it having in mind that if the logicalId is already different from null, it will return it directly without generation, as some times we still want to manually provide the value, and sometimes we need it to be generated.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.