Search code examples
javadate-rangelocaldatetime

Calculate date range match into Java


I want to implement this code to check if a user is subscribed to a service:

public String calculateSubscription(String email) {

    Optional<Subscription> subscriptionsByUserEmail = subscriptionService.findSubscriptionsByUserEmail(email);
    if (subscriptionsByUserEmail.isPresent()) {
      Subscription subscription = subscriptionsByUserEmail.get();

      LocalDateTime startAt = subscription.getStartAt();
      LocalDateTime endAt = subscription.getEndAt();
      LocalDateTime now = LocalDateTime.now();

      if(/* do here comparison */){
        return "subscribed";
      }
    }
    return "unsubscribed";
  }

Into database I store 2 fields:

@Column(name = "start_at")
@Convert(converter = LocalDateTimeConverter.class)
private LocalDateTime startAt;

@Column(name = "end_at")
@Convert(converter = LocalDateTimeConverter.class)
private LocalDateTime endAt;

I need to check if now variable is matched between the time interval startAt and endAt.

How could this check be implemented into the if statement?

EDIT:

@Converter(autoApply = true)
public class LocalDateTimeConverter implements AttributeConverter<LocalDateTime, Timestamp> {
    @Override
    public Timestamp convertToDatabaseColumn(LocalDateTime localDateTime) {
        return Optional.ofNullable(localDateTime)
                .map(Timestamp::valueOf)
                .orElse(null);
    }

    @Override
    public LocalDateTime convertToEntityAttribute(Timestamp timestamp) {
        return Optional.ofNullable(timestamp)
                .map(Timestamp::toLocalDateTime)
                .orElse(null);
    }
}

Solution

  • Two points:

    1. Don’t use LocalDateTime for a point in time. Anyone reading it will be free to interpret it in any time zone they can think of, for a variation of interpretations of 24 to 26 hours. Instant is the class to use for a point in time. It has isBefore and isAfter methods just like LocalDateTime, so the code will not be different. Alternatives that also do define a point in time include ZonedDateTime and OffsetDateTime.
    2. With Optional don’t use its isPresent and get methods (except in rare cases). They are low-level, and by far the most often we can obtain more elegant code with higher-level methods.

    I am rather into method chaining with Optional, so my code would look like:

    public String calculateSubscription(String email) {
        Instant now = Instant.now();
        return subscriptionService.findSubscriptionsByUserEmail(email)
                .filter(sub -> ! sub.getStartAt().isAfter(now) && sub.getEndAt().isAfter(now))
                .map(sub -> "subscribed")
                .orElse("unsubscribed");
    }
    

    This works if getStartAt() and getEndAt() return Instant. For consistent results in corner cases I read the clock only once (not twice within the filter condition). Standard interpretation of a time interval is half-open, from start inclusive to end exclusive, so my code uses this. I am using “not after” to mean “on or before”. You may not need this precision in your case.

    For storing a point in time into your database, with most database engines you need a timestamp with time zone.

    Establish points in time if you can and only if you can

    Edit: To elaborate a bit, a date and time of day, which are what a LocalDateTime gives us, do not establish a point in time. June 30 11:45 in Tokyo is 16 hours before June 30 11:45 in Los Angeles, for example. So for a point in time we additionally need either a time zone or a UTC offset.

    If your database can be made to provide a point in time, typically through a timestamp with time zone as mentioned, we’re happy. Exactly how to retrieve it into Java depends on your JPA version and implementation, so I’d rather not try to be specific on that point. You may or may not need to use OffsetDateTime (or even ZonedDateTime) rather than Instant in Java or to provide a different custom converter from the one you are using now. The latter may be as easy using Timestamp::toInstant instead of Timestamp::toLocalDateTime.

    If your database only gives you date and time of day, for example if it uses its datetime data type and this cannot be changed, don’t try to pretend otherwise. In this case insisting on converting to Instant may do more harm than good. No chain is stronger than its weakest link anyway. So in this case stick to LocalDateTime throughout in Java. Then read the clock in this fashion:

        ZoneId timeZoneThatTheDatabaseAssumes = ZoneId.of("Europe/Gibraltar");
        LocalDateTime now = LocalDateTime.now(timeZoneThatTheDatabaseAssumes);
    

    Of course specify the time zone ID for the time zone that your database uses.