skip to Main Content

I have a Spring data JPA repository which is backed by a postgresql database. As part of the query I want to use an interval in the query. I managed to achieve it by creating an SQLFunction that casts the string like this:

  public String render(Type firstArgumentType, List args, SessionFactoryImplementor factory)
      throws QueryException {
    return "cast(" + args.get(0) + " as interval)";
  }

It works, but in reality it does not translate to an interval type but a string (a cstring) which is then recast by postgresql to an interval. While this works, it has some undesirable implications when it is being passed to another SQL function (for example in some cases a server-side prepared statement parameter that is expected to be an interval but is a cstring ends up being inefficient in large queries).

One such example is the time_bucket function by the timescaledb extension, which expects an interval as its first argument. I added the function to a class that extends PostgisPG95Dialect as follows:

this.registerFunction("time_bucket", new StandardSQLFunction("time_bucket", new OffsetDateTimeType()));

But when I try to pass its argument, it is never passed as an interval, irrespective of whether I use string syntax as above, or a Duration mapped as shown below.

I know that the postgresql driver has the PGInterval type which is supposed to achieve this. However, I can’t get it to work with Spring Data JPA parameters.

I tried to pass PGInterval directly but instead it gets translated to a bytea.

I tried to use a Converter with the Duration class, but it doesn’t seem to be invoked and the parameter is sent as bigint instead.

@Converter
public class DurationConverter implements AttributeConverter<Duration, PGInterval> {

    @Override
    public PGInterval convertToDatabaseColumn(Duration duration) {
        try {
            return new PGInterval(duration.toString());
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public Duration convertToEntityAttribute(PGInterval pgInterval) {
        if (pgInterval.getYears() == 0 && pgInterval.getMonths() == 0) {
            return Duration.ofDays(pgInterval.getDays())
                    .plusHours(pgInterval.getHours())
                    .plusMinutes(pgInterval.getMinutes())
                    .plusSeconds(pgInterval.getWholeSeconds())
                    .plusMillis(pgInterval.getMicroSeconds() / 1000);
        }
        else {
            throw new RuntimeException(String.format("Cannot convert interval with %s years and %s months to Duration that needs a precise interval", pgInterval.getYears(), pgInterval.getMonths()));
        }
    }
}

I even tried to create a Hibernate UserType:

@Component
@TypeDef(defaultForType = Duration.class, typeClass = DurationType.class)
public class DurationType implements UserType {

  public int[] sqlTypes() {
    return new int[] {Types.OTHER};
  }

  public Class<?> returnedClass() {
    return Duration.class;
  }

  @Override
  public boolean equals(Object o1, Object o2) throws HibernateException {
    return o1.equals(o2);
  }

  @Override
  public int hashCode(Object o) throws HibernateException {
    return o.hashCode();
  }

  @Override
  public Object nullSafeGet(
      ResultSet resultSet,
      String[] names,
      SharedSessionContractImplementor sharedSessionContractImplementor,
      Object o)
      throws HibernateException, SQLException {
    try {
      final PGInterval pgi = (PGInterval) resultSet.getObject(names[0]);

      final int years = pgi.getYears();
      final int months = pgi.getMonths();
      final int days = pgi.getDays();
      final int hours = pgi.getHours();
      final int mins = pgi.getMinutes();
      final int seconds = pgi.getWholeSeconds();
      final int microseconds = pgi.getMicroSeconds();

      if (years == 0 && months == 0) {
        return Duration.ofDays(days)
            .plusHours(hours)
            .plusMinutes(mins)
            .plusSeconds(seconds)
            .plusMillis(microseconds / 1000);
      } else {
        return null;
      }

    } catch (Exception e) {
      return null;
    }
  }

  @Override
  public void nullSafeSet(
      PreparedStatement statement,
      Object value,
      int index,
      SharedSessionContractImplementor sharedSessionContractImplementor)
      throws HibernateException, SQLException {
    if (value == null) {
      statement.setNull(index, Types.OTHER);
    } else {

      final Duration duration = ((Duration) value);

      final int days = (int) duration.toDaysPart();
      final int hours = duration.toHoursPart();
      final int mins = duration.toMinutesPart();
      final int secs = duration.toSecondsPart();

      final PGInterval pgi = new PGInterval(0, 0, days, hours, mins, secs);
      statement.setObject(index, pgi);
    }
  }


  public boolean isMutable() {
    return false;
  }

  public Serializable disassemble(Object value) throws HibernateException {
    throw new HibernateException("not implemented");
  }

  public Object assemble(Serializable cached, Object owner) throws HibernateException {
    throw new HibernateException("not implemented");
  }

  public Object replace(Object original, Object target, Object owner) throws HibernateException {
    throw new HibernateException("not implemented");
  }
}

But in this case, Duration gets translated to bigint.

Is there a way to get Spring Data JPA to send an interval when translating query method parameters?

2

Answers


  1. Chosen as BEST ANSWER

    After trying out all suggestions, the only way I managed to pass an interval was by registering another function make_interval which is provided by postgresql.

    In my case I only needed the minutes version, so I defined it as:

    this.registerFunction("make_interval", new SQLFunctionTemplate(new PostgreSQLIntervalType(), "make_interval(mins=>?1)"));
    

    Then I could put something like this in my repository:

    @Query("select function('time_bucket', make_interval(:mins), vt.ts) as startTime, count(vt) as total from MyEntity vt group by startTime order by startTime)"
    

    where :mins is an integer and time_bucket was also registered in a similar way.

    this.registerFunction("time_bucket", new StandardSQLFunction("time_bucket", new OffsetDateTimeType()));
    

  2. We can map PostgreSQL Interval to Java Duration by using Hibernate annotations:

    Hibernate 6:

    @Entity
    @Table
    public class SampleEntity {
    
        @Type(PostgreSQLIntervalType.class)
        @Column(
            name = "presale_period",
            columnDefinition = "interval"
        )
        private Duration presalePeriod;
    }
    

    Hibernate 5:

    @Entity
    @Table
    @TypeDef(typeClass = PostgreSQLIntervalType.class, defaultForType = Duration.class)
    public class SampleEntity {
     
        @Column(
            name = "presale_period",
            columnDefinition = "interval"
        )
        private Duration presalePeriod;
    }
    

    And the spring-jpa repository:

    public interface SampleEntityRepository extends JpaRepository<SampleEntity, UUID>{
    
      // Drived query
      Collection<SampleEntity> findBySalePeriodGreaterThan(Duration duration);
    
      // HQL
      @Query("select p from Product p where p.salePeriod < ?1")
      Collection<SampleEntity> findBySalePeriodLessThan(Duration duration);
    }
    

    More details you can refer to this post

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search