Transfer date from form to database

There are Angular, PrimeNG, Spring Boot, JDBC, PostgreSQL

I need to save date and time from form to database and show it again.

Prepare DB

create database test_date;
CREATE TABLE test_table (
	test_date date NULL,
	test_timestamp timestamp NULL,
	test_timestamptz timestamptz NULL,
	id serial2,
	CONSTRAINT test_table_pk PRIMARY KEY (id)
);

java.util.Date

Solution 1

I need just date without a time. I will use the column with type date. Choose 2020-12-22 on the form. 2020-12-21T21:00:00.000Z is sent. It is a begining of day in UTC, because browser is in +3. Java makes statement

statement.setObject(1, entity.getTestDate(), Types.DATE)
insert into test_table (test_date) values ('2020-12-22 +03')

JDBC cuts the time out and adds time zone (by default system zone or -Duser.timezone=Europe/Moscow). Postgres doesn't look at zone for column date. 2020-12-22 is saved. Same is read from DB. Result Json is

{ "testDate": "2020-12-22" }

Browser interprets this format as a day start in UTC.

new Date('2020-12-22')
new Date('2020-12-22T00:00:00.000+00:00')
Tue Dec 22 2020 03:00:00 GMT+0300 (Moscow Standard Time)

I see on form 2020-12-22 03:00 or just 2020-12-22 without a time. It is right.

I have faced a situation where Chrome and Firefox interpreted a date without a time differently. Someone did it as the beginning of the day in local time. This is not currently reproduced on updated versions. The documentation says that this format is now standardized. But if the string differs from the format 2020-12-22T00:00:00.000+00:00, then the behavior is not guaranteed.

Error

Error rises if there is user that is east of sever's time zone. For example Europe/Samara (+4). He choose 2020-12-22. Browser sends 2020-12-21T20:00:00.000Z (2020-12-22 00:00 +4). Server (works in +3) translate it to 2020-12-21T23:00:00.000+03:00, cuts time out and inserts it as

insert into test_table (test_date) values ('2020-12-21 +03')

Then server returns 2020-12-21. Browser makes 2020-12-21 04:00. User sees 2020-12-21. Error.

Solution 2

Specify the user's time zone, not the server's zone, when saving to the database.

String userZoneId = entity.getZoneId();
statement.setDate(1, new java.sql.Date(entity.getTestDate().getTime()),
        Calendar.getInstance(TimeZone.getTimeZone(userZoneId)));

I can get it as a separate parameter in the request. I can do this in JavaScript.

Intl.DateTimeFormat().resolvedOptions().timeZone;

I can try new API with polyfill: Temporal.now().timeZone().id.

Request:

{"testDate":"2020-12-21T20:00:00.000Z","zoneId":"Europe/Samara"}

Saving to DB:

insert into test_table (test_date) values ('2020-12-22 +04')

Knowing the zone, the JDBC driver transformes 2020-12-21T23:00:00.000+03:00 to 2020-12-22T00:00:00.000+04:00, and makes 2020-12-22 +04. When reading it turns to 2020-12-22 -> 2020-12-22 04:00:00. 2020-12-22 is on the form. All right.

Error

Now let's test the situation where the user is west of UTC. For example America/Chicago (-6). The selected date 2020-12-22 saved well. When reading, the server returns it back, but it turns into 2020-12-21 18:00 in user's local time and is displayed as 2020-12-21.

Solution 3

The server should return the date with the time 2020-12-22T00:00:00.000 and without a zone, then it will be converted by the browser to the beginning of the day according to local time. For this I will make a date serializer

import java.text.SimpleDateFormat;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;

public class DateSerializer extends JsonSerializer<Date> {

	private final static SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");

	@Override
	public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers)
      throws IOException {
		gen.writeString(format.format(value));
	}
}

Now it is all right. The selected date is saved in the DB correctly. And it returns back. Tested on different time zones. Ready for prod.

Error

There will be bug on prod. User may see wrong date. It is not a day before or a day after, it is another user's date.

Solution 4

java.text.SimpleDateFormat.format() is not thread safe. But I created it once for the whole application. I must create new instance for each serialization.

private static final String format = "yyyy-MM-dd'T'HH:mm:ss";

@Override
public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers)
    throws IOException {
  gen.writeString(new SimpleDateFormat(format).format(value));
}

The result is a service with a strange interface for saving the date. In another client, I have to transfer not only the date, but also calculate the time and transfer the zone for which this time is calculated. If I want to save 2020-12-22, then I must first decide on the zone. If it is +3, then I need to transfer

{"testDate":"2020-12-21T21:00:00.000Z","zoneId":"Europe/Moscow"}

or

{"testDate":"2020-12-22T00:00:00.000+03:00","zoneId":"Europe/Moscow"}

In the last one there is the duplication of necessary information about the offset. I should remove zoneId and leave the date in the format 2020-12-22. If request contains 2020-12-21T21:00:00.000Z, then ignore a time and a zone parts - save it as 2020-12-21). The result should be returned without time too.

Solution 5

I remove my serializer from java. At the front, it is necessary to process 2020-12-22 as the beginning of the day according the local time.

const ymd: string[] = obj.testDate.split('-');
const date: Date = new Date(ymd);

It is convenient and works in Chrome and Firefox. But the constructor with the Array parameter is not described in the standard. Therefore, the parameter is converted to a string and passed to Date.parse(). But this method works by default only for 2020-12-22. So, I write code to make string according to the standard.

const ymd: number[] = obj.testDate.split('-').map((s: string) => Number(s));
const date: Date = new Date(ymd[0], ymd[1] - 1, ymd[2])

The next step is to discard time when saving. JDBC already does this. But this causes error for east users. Because the time is discarded from the instant by server time. So, the time must be cuted off before converting the string to a date according to server time. Another problem appears here: the browser sends the date as the beginning of the day in UTC time. That is, the code must be written on the front before sending it to the server.

public saveEntity(entity: TestEntity): Observable<number> {
  const date: Date = entity.testDate;
	const testDate: string = [date.getFullYear(), date.getMonth() + 1, date.getDate()]
		.map(n => String(n).padStart(2, '0')).join('-');
  const body: any = Object.assign({}, entity, {testDate});
  return this.http.post<number>(CONTROLLER, body);
}

This solution works, it works for users from all zones. And I don't need to program anything on the server.

Error

Then a developer from Chicago appears on the project. And he tests the application on his computer. 2020-12-22 is sent to the server. The server turns this into 2020-12-21 18:00:00. And saves as

insert into test_table (test_date) values ('2020-12-21 -06')

Bug. Solution works only on a server east of UTC.

Solution 6

The simplest solution is to hardcode time zone.

System.setProperty("user.timezone", "UTC")

But it is not entirely correct. What if the application already has a lot of logic depending on the fact that the server is located somewhere in the local time in the west? The problem is that Jackson treats the received date as the start of the day in UTC. I want the date to be the start of the day for the server, so I have to hardcode that zone and tell Jackson to use the server's zone for conversion.

public class TestDateApplication {

	public static final String APP_TIMEZONE = "America/Chicago";

	public static void main(String[] args) {
		System.setProperty("user.timezone", APP_TIMEZONE);
		SpringApplication.run(TestDateApplication.class, args);
	}
import com.fasterxml.jackson.annotation.JsonFormat;

public class TestEntity {
	private int id;
	@JsonFormat(timezone = TestDateApplication.APP_TIMEZONE, pattern = "yyyy-MM-dd")
	private Date testDate;

Since the browser now send the date without a time, I can restrict the interface and not allow the format with time. If someone starts sending the time, then an error with the time zones is possible.

Solution 7

Jackson skips dates like this by not counting the time. Therefore, I need to write my own deserializer. It throws an exception if the string is longer than the specified format.

import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;

public class DateDeserializer extends JsonDeserializer<Date> {

  private static final String format = "yyyy-MM-dd";

  @Override
  public Date deserialize(JsonParser p, DeserializationContext ctxt)
      throws IOException, JsonProcessingException {
    if (p.hasToken(JsonToken.VALUE_STRING)) {
      String text = p.getText().trim();
      if (text.length() != format.length()) {
        throw new InvalidFormatException(p, "Wrong date", text, Date.class);
      }
      try {
        Date result = new SimpleDateFormat(format).parse(text);
        return result;
      } catch (ParseException e) {
        throw new InvalidFormatException(p, "Wrong date", text, Date.class);
      }
    }
    return (Date) ctxt.handleUnexpectedToken(Date.class, p);
  }
}

If I save the date in the east and open it in the west, then this date is the same. But in the west it may be only yesterday. This may be a mistake, depends on the task. If we are talking about the date of birth, then there is no mistake. If about the publication date of the news, then the reader in the west may see news from the future. It might look strange.

Solution 8

For such a case, I have to store the time along with the date. It depends on whether the day is the same for two time zones at this time or is different. Two types can be used for DB column: timestamp or timestamp with time zone. It seems, I don't need to store the zone, so I will use a timestamp.

private static final String COLUMN_LABEL = "test_timestamp";

entity.setTestDate(rs.getTimestamp(COLUMN_LABEL));

statement.setTimestamp(1, new Timestamp(entity.getTestDate().getTime()));

The date 2020-12-21T20:00:00.000Z come from front. It is transferred to the base as

insert into test_table (test_timestamptz) values ('2020-12-21 14:00:00-06')

And is saved as 2020-12-21 14:00:00. Time 2020-12-21T20:00:00.000+00:00 returns to front and user see local time. It works.

Error

There is error, if server's time zone changes. The time in DB is time in server's zone. Another server in different time zone reads wrong time. Time 2020-12-21 14:00:00 on Europe/Moscow server becomes 2020-12-21T11:00:00.000+00:00. Should be 2020-12-21T20:00:00.000+00:00.

Solution 9

The server must always be in the same zone. Or it is necessary to store dates in one zone and indicate it explicitly. Since the server was previously in America/Chicago and the time is stored in such a zone, I will hard code this zone

private static final String COLUMN_TIMEZONE = "America/Chicago";

entity.setTestDate(rs.getTimestamp(COLUMN_LABEL,
        Calendar.getInstance(TimeZone.getTimeZone(COLUMN_TIMEZONE))));

statement.setTimestamp(1, new Timestamp(entity.getTestDate().getTime()),
        Calendar.getInstance(TimeZone.getTimeZone(COLUMN_TIMEZONE)));

To make it easier to debug, it is better to make the zone UTC. And I convert the time to UTC in the database.

update test_table
set test_timestamp =
    (test_timestamp at time zone 'America/Chicago') at time zone 'UTC';
private static final String COLUMN_TIMEZONE = "UTC";

2020-12-21T20:00:00.000Z is come from front. Server sends to DB

insert into test_table (test_timestamp) values ('2020-12-21 20:00:00+00')

It is stored as 2020-12-21 20:00:00. It will be readed as 2020-12-21 14:00:00 on server (-6). 2020-12-21T20:00:00.000+00:00 returns on front. It contains time zone and is shown on the form as local time.

Solution 10

It turned out the same thing that could be done right away using timestamp with time zone. This type does not store a zone. It stores the time for the UTC zone and automatically converts it to the time for the other zone. So, the code can be rewritten without specifying the zone when saving and reading.

private static final String COLUMN_LABEL = "test_timestamptz";

entity.setTestDate(rs.getTimestamp(COLUMN_LABEL));

statement.setTimestamp(1, new Timestamp(entity.getTestDate().getTime()));

2020-12-21T20:00:00.000Z comes from front. It is sent to DB as

insert into test_table (test_timestamptz) values ('2020-12-21 14:00:00-06')

-06 is because of java server timezone. And it is saved in the database regardless of the postgresql zone as the time 2020-12-21T20:00:00.000Z. When reading, the server receives 2020-12-21 14:00:00 according to java server timezone. The time comes to the front with the indication of the zone 2020-12-21T20:00:00.000+00:00 and the local time is shown.

Solution 11

I need the time is always shown what was selected on the form, and does not depend on the time zone of the browser or server.
For network transmission I use the format without specifying the zone. For storage I use the timestamp column.

public saveEntity(entity: TestEntity): Observable<number> {
	const date: Date = entity.testDate;
	const testDate: string = [date.getFullYear(), date.getMonth() + 1, date.getDate()]
		.map(n => String(n).padStart(2, '0')).join('-')
		+ 'T' + [date.getHours(), date.getMinutes(), date.getSeconds()]
		.map(n => String(n).padStart(2, '0')).join(':');
	const body: any = Object.assign({}, entity, {testDate});
	return this.http.post<number>(CONTROLLER, body);
}
@JsonDeserialize(using = DateDeserializer.class)
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss",
  timezone = TestDateApplication.APP_TIMEZONE)
private Date testDate;

In deserializer:

Date result = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(text);

I choose 2020-12-22 14:14 on the form. 2020-12-22T14:14:00 is sent. This format is translated as server local time. The same is sent to DB.

insert into test_table (test_timestamp) values ('2020-12-22 14:14:00+04')

Since the column type is timestamp without time zone, the transferred zone is discarded, and there is no additional conversion. When reading, everything is the same.

new Date('2020-12-22T14:14:00')
Tue Dec 22 2020 14:14:00 GMT-0600 (Central Standard Time)

Browser displays what was saved in any time zone.

Time API

Solution 1

I need just date without time. Column type - date. DTO field type - LocalDate.

private LocalDate testDate;

statement.setObject(1, entity.getTestDate(), Types.DATE);

entity.setTestDate(rs.getObject("test_date", LocalDate.class));

Choose 2020-12-22. Browser sends 2020-12-21T21:00:00.000Z. Jackson translates it to LocalDateTime for zone UTC and cuts time off.

insert into test_table (test_date) values ('2020-12-21'::date)

Date 2020-12-21 returns back.

new Date('2020-12-21')
Mon Dec 21 2020 03:00:00 GMT+0300 (Moscow Standard Time)

Wrong.

Solution 2

I need to deserialize with server's time zone.

public LocalDate deserialize(JsonParser p, DeserializationContext ctxt)
        throws IOException, JsonProcessingException {
    if (p.hasToken(JsonToken.VALUE_STRING)) {
        String text = p.getText().trim();
        try {
            DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                    .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
                    .appendZoneId()
                    .toFormatter();
            LocalDate result = ZonedDateTime.parse(text, formatter)
                    .withZoneSameInstant(ZoneId.systemDefault())
                    .toLocalDate();
            return result;
        } catch (Exception e) {
            throw new InvalidFormatException(p, "Wrong date", text, Date.class);
        }
    }
    return (LocalDate) ctxt.handleUnexpectedToken(LocalDate.class, p);
}

Now DB gets the right date.

insert into test_table (test_date) values ('2020-12-22'::date)

But this doesn't work for user east of the server.

Solution 3

I can solve this by transferring the user's zone. So I need to change the field type to the type with time: LocalDateTime.

String zoneId = entity.getZoneId();
statement.setObject(1,
    ZonedDateTime.of(entity.getTestDate(), ZoneId.systemDefault())
      .withZoneSameInstant(ZoneId.of(zoneId))
      .toLocalDate(),
    Types.DATE);
entity.setTestDate(
		LocalDateTime.of(rs.getObject(COLUMN_LABEL, LocalDate.class), LocalTime.MIN));

The date is returned from the server with the time: 2021-12-22T00:00:00. Therefore, this solution will work for users from west as well.

Solution 4

If I form a date without time on the front, then on the server I can leave only a field with the LocalDate type. And there are no more additional conversions.

statement.setObject(1, entity.getTestDate(), Types.DATE);

entity.setTestDate(rs.getObject(COLUMN_LABEL, LocalDate.class));

This solution works, even I move the server to another time zone.

To prevent the error from transferring the date in ISO format with time, it is enough to specify the format.

@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate testDate;

Jackson throws an exception at additional characters.

Solution 5

I can use LocalDateTime to save time. But to convert the string 2020-12-21T20:00:00.000Z to local time, I need a deserializer using ZoneDateTime. So I will use this type for field.

statement.setObject(1,
    entity.getTestDate()
        .withZoneSameInstant(ZoneId.systemDefault())
        .toLocalDateTime(),
    Types.TIMESTAMP);

entity.setTestDate(
    ZonedDateTime.of(
        rs.getObject(COLUMN_LABEL, LocalDateTime.class),
        ZoneId.systemDefault()
        )
    );

But if I change server's time zone, all saved dates is broken.

Solution 6

I hard code time zone to save date in DB.

private static final String COLUMN_TIMEZONE = "UTC";

statement.setObject(1,
    entity.getTestDate()
      .withZoneSameInstant(ZoneId.of(COLUMN_TIMEZONE))
      .toLocalDateTime(),
    Types.TIMESTAMP);

entity.setTestDate(
    ZonedDateTime.of(
        rs.getObject(COLUMN_LABEL, LocalDateTime.class),
        ZoneId.of(COLUMN_TIMEZONE)
        )
    );

Solution 7

Now I can switch to the column type timestamptz. To insert ZonedDateTime in such column, I can use LocalDateTime. But I must to convert the time to the server's time zone.

statement.setObject(1,
    entity.getTestDate()
      .withZoneSameInstant(ZoneId.systemDefault())
      .toLocalDateTime());

Because JDBC adds an offset based on the server's zone to the request. Postgres uses this offset for conversion to UTC.

insert into test_table (test_timestamptz)
values ('2020-12-21 23:30:00+03'::timestamp)

I can use OffsetDateTime.

statement.setObject(1, entity.getTestDate().toOffsetDateTime());

Then what came from the browser will be transferred to DB.

insert into test_table (test_timestamptz)
values ('2020-12-21 20:30:00+00'::timestamp with time zone)

JDBC can read only OffsetDateTime from timestamptz.

entity.setTestDate(
    rs.getObject(COLUMN_LABEL, OffsetDateTime.class).toZonedDateTime()
    );

So I change the DTO field to OffsetDateTime.

Solution 8

To save the selected time and display it regardless of the browser zone or server zone, it is enough to send the local time without zone.

public saveEntity(entity: TestEntity): Observable<number> {
	const date: Date = entity.testDate;
	const testDate: string = [date.getFullYear(), date.getMonth() + 1, date.getDate()]
		.map(n => String(n).padStart(2, '0')).join('-')
		+ 'T' + [date.getHours(), date.getMinutes(), date.getSeconds()]
		.map(n => String(n).padStart(2, '0')).join(':');
	const body: any = Object.assign({}, entity, {testDate});
	return this.http.post<number>(CONTROLLER, body);
}

I use LocalDateTime and timestamp.

statement.setObject(1, entity.getTestDate());

entity.setTestDate(rs.getObject(COLUMN_LABEL, LocalDateTime.class));

No additional Jackson setup is needed. When transferring time with the zone, Jackson throws error.

Conclusion

To avoid some mistakes, I should make some setup from start: the date format in request and response, the zone in which the Java server works, the time zone in which the time is stored in DB.

Additional errors may appear due to outdated tzdata. PostgreSQL has its own tzdata. If there are columns timestamptz, then tzdata is used. Minor releases need to be tracked. If Postgres is compiled with the with-system-tzdata flag, then I need to update the system tzdata. Java has its own tzdata. I need to update it too. Update can be made apart from full jre update. Joda-time has its own tzdata.

All solutions are available in repository https://github.com/Qwertovsky/test_date.