Postgresql: создайте запрос, который использует generate_series с интервалом, который корректно учитывает изменения DST и выравнивает в истинные календарные дни - PullRequest
2 голосов
/ 22 мая 2019

Как комментарий к моему вопросу на Возможно ли этот запрос, который пытается получить статусы временных рядов с усеченными датами, даже в обычных реляционных базах данных? Я реализовал запрос временных рядов на postgres, который работает достаточно хорошо , Он выравнивает время на целые периоды (например, дни) и объединяет его с некоторыми данными.

Тем не менее, существует серьезная проблема: запрос зависит от часового пояса, который работает нормально, но когда летнее время (DST) происходит в середине сгенерированного ряда, это не отражается в выходных данных. К сожалению, в некоторых часовых поясах 1 день в году занимает всего 23 часа, а другой - 25 часов. Мне нужно, чтобы данные были агрегированы за этот период 23 или 25 часов, потому что это истинные календарные дни в этом часовом поясе. Но с текущим запросом он просто всегда добавляет 1 день к серии. Это означает, что во время перехода на летнее время я получаю выходные данные с такими данными:

date 1: 00:00
date 2: 00:00
date 3: 00:00
(now a DST change happens)
date 3: 23:00
date 4: 23:00
... and so on

Я не знаю, как переписать этот запрос, чтобы учесть, что определенные дни занимают меньше или больше часов в некоторых часовых поясах. Потому что generate_series основан на интервалах. Есть идеи? Фактический код имеет произвольный период и сумму между прочим, это также может быть 5 месяцев или 3 часа.

Вот полный запрос, хотя я думаю, что релевантным является только запрос sub1.

SELECT sub2.fromdate,
       sub2.eventlevel,
       sub2.count
FROM
  (SELECT sub1.fromdate AS fromdate,
          sub1.maxeventlevel AS eventlevel,
          count(*) AS COUNT
   FROM
     (SELECT e.subject_id,
             MAX(e.event_level) AS maxeventlevel,
             d.date AS fromdate
      FROM
        (SELECT generate_series(date_trunc(?, ? AT TIME ZONE ?) AT TIME ZONE ?, date_trunc(?, ? AT TIME ZONE ?) AT TIME ZONE ? , interval '1' DAY)) d(date)
      INNER JOIN event e ON ((e.end_date > d.date
                              AND e.end_date > ?)
                             OR e.end_date IS NULL)
      AND e.date < d.date + interval '1' DAY
      AND e.date < ?
      AND d.date < ?
      INNER JOIN subject ON subject.id = e.subject_id
      INNER JOIN metric ON metric.id = e.metric_id
      INNER JOIN event_configuration_version ON event_configuration_version.id = e.event_configuration_version_id
      INNER JOIN event_configuration ON event_configuration.id = event_configuration_version.event_configuration_id
      WHERE subject.project_id = ?
      GROUP BY e.subject_id,
               fromdate) AS sub1
   GROUP BY sub1.fromdate,
            sub1.maxeventlevel) AS sub2
ORDER BY sub2.fromdate,
         sub2.eventlevel DESC

Я не думаю, что смогу что-либо сделать в коде после того, как запрос уже выполнен, но я открыт для любых пропущенных решений кода, хотя в идеале мы получаем правильные результаты обратно из самого SQL-запроса. , Нам нужно выполнить большую часть агрегации в самой базе данных, но если есть что-то умное, что можно сделать в другом месте, то это тоже работает. Код Java, генерирующий и выполняющий этот запрос и преобразующий результат, запускается в приложении Spring Boot и выглядит следующим образом:

public PeriodAggregationDTO[] getSubjectStatesReport(
    AggregationPeriod aggregationPeriod, Integer aggregationPeriodAmount, UUID projectId,
    List<UUID> eventTriggerIds, List<UUID> subjectIds, List<UUID> metricIds, List<EventLevel> eventLevels,
    Date fromDate, Date toDate) {
    // to avoid an even more complex native query, we obtain the project here so a) we are sure
    // that this user has access
    // and b) we can get the timezone already without additional joins later.

    Project project = serviceUtil.findProjectByIdOrThrowApiException(projectId);
    String timezoneId = project.getTimezoneId();

    boolean skipEventTriggers = eventTriggerIds == null || eventTriggerIds.size() == 0;
    boolean skipSubjects = subjectIds == null || subjectIds.size() == 0;
    boolean skipMetrics = metricIds == null || metricIds.size() == 0;
    boolean skipEventLevels = eventLevels == null || eventLevels.size() == 0;

    StringBuilder whereClause = new StringBuilder();
    whereClause.append(" WHERE subject.project_id = :projectId");
    if (!skipEventTriggers) {
        whereClause.append(" AND event_trigger.id in :eventTriggerIds");
    }
    if (!skipSubjects) {
        whereClause.append(" AND subject_id in :subjectIds");
    }
    if (!skipMetrics) {
        whereClause.append(" AND metric.id in :metricIds");
    }
    if (!skipEventLevels) {
        whereClause.append(" AND e.event_level in :eventLevels");
    }

    String interval = String.format("'%d' %s", aggregationPeriodAmount, aggregationPeriod);

    String series = "SELECT generate_series("
        + "date_trunc(:period, :fromDate AT TIME ZONE :timezoneId) AT TIME ZONE :timezoneId"
        + " , date_trunc(:period, :toDate AT TIME ZONE :timezoneId) AT TIME ZONE :timezoneId"
        + " , interval " + interval + ")";

    String innersubquery = "SELECT e.subject_id" + ",MAX(e.event_level) as maxeventlevel"
        + ",d.date as fromdate"
        + " FROM (" + series + " ) d(date)"
        + " INNER JOIN event e ON ((e.end_date > d.date AND e.end_date > :fromDate)"
        + " OR e.end_date IS NULL) AND e.date < d.date + interval " + interval
        + " AND e.date < :toDate AND d.date < :toDate"
        + " INNER JOIN subject ON subject.id = e.subject_id"
        + " INNER JOIN metric ON metric.id = e.metric_id"
        + " INNER JOIN event_trigger_version ON event_trigger_version.id = e.event_trigger_version_id"
        + " INNER JOIN event_trigger ON event_trigger.id = event_trigger_version.event_trigger_id"
        + whereClause.toString()
        + " GROUP BY e.subject_id, fromdate";

    String outersubquery = "SELECT" + " sub1.fromdate as fromdate"
        + ",sub1.maxeventlevel as eventlevel" + ",count(*) as count" + " FROM"
        + " (" + innersubquery + ") AS sub1"
        + " GROUP BY sub1.fromdate, sub1.maxeventlevel";

    String queryString = "SELECT sub2.fromdate, sub2.eventlevel, sub2.count FROM ("
        + outersubquery + ") AS sub2"
        + " ORDER BY sub2.fromdate, sub2.eventlevel DESC";

    Query query = em.createNativeQuery(queryString);

    query.setParameter("projectId", projectId);
    query.setParameter("timezoneId", timezoneId);
    query.setParameter("period", aggregationPeriod.toString());
    query.setParameter("fromDate", fromDate);
    query.setParameter("toDate", toDate);
    if (!skipEventTriggers) {
        query.setParameter("eventTriggerIds", eventTriggerIds);
    }
    if (!skipSubjects) {
        query.setParameter("subjectIds", subjectIds);
    }
    if (!skipMetrics) {
        query.setParameter("metricIds", metricIds);
    }
    if (!skipEventLevels) {
        List<Integer> eventLevelOrdinals =
            eventLevels.stream().map(Enum::ordinal).collect(Collectors.toList());
        query.setParameter("eventLevels", eventLevelOrdinals);
    }

    List<?> resultList = query.getResultList();

    Stream<AggregateQueryEntity> stream = resultList.stream().map(obj -> {
        Object[] array = (Object[]) obj;
        Timestamp timestamp = (Timestamp) array[0];
        Integer eventLevelOrdinal = (Integer) array[1];
        EventLevel eventLevel = EventLevel.values()[eventLevelOrdinal];
        BigInteger count = (BigInteger) array[2];
        return new AggregateQueryEntity(timestamp, eventLevel, count.longValue());
    });
    return transformQueryResult(stream);
}

private PeriodAggregationDTO[] transformQueryResult(Stream<AggregateQueryEntity> stream) {
    // we specifically use LinkedHashMap to maintain ordering. We also set Linkedlist explicitly
    // because there are no guarantees for this list type with toList()
    Map<Timestamp, List<AggregateQueryEntity>> aggregatesByDate = stream
        .collect(Collectors.groupingBy(AggregateQueryEntity::getTimestamp,
            LinkedHashMap::new, Collectors.toCollection(LinkedList::new)));

    return aggregatesByDate.entrySet().stream().map(entryByDate -> {
        PeriodAggregationDTO dto = new PeriodAggregationDTO();
        dto.setFromDate((Date.from(entryByDate.getKey().toInstant())));
        List<AggregateQueryEntity> value = entryByDate.getValue();
        List<EventLevelAggregationDTO> eventLevelAggregationDTOS = getAggregatesByEventLevel(value);
        dto.setEventLevels(eventLevelAggregationDTOS);
        return dto;
    }).toArray(PeriodAggregationDTO[]::new);
}

private List<EventLevelAggregationDTO> getAggregatesByEventLevel(
    List<AggregateQueryEntity> value) {
    Map<EventLevel, AggregateQueryEntity> aggregatesByEventLevel = value.stream()
        .collect(Collectors.toMap(AggregateQueryEntity::getEventLevel, Function.identity(), (u, v) -> {
            throw new InternalException(String.format("Unexpected duplicate event level %s", u));
        }, LinkedHashMap::new));
    return aggregatesByEventLevel.values().stream().map(aggregateQueryEntity -> {
        EventLevelAggregationDTO eventLevelAggregationDTO = new EventLevelAggregationDTO();
        eventLevelAggregationDTO.setEventLevel(aggregateQueryEntity.getEventLevel());
        eventLevelAggregationDTO.setCount(aggregateQueryEntity.getCount());
        return eventLevelAggregationDTO;
    }).collect(Collectors.toCollection(LinkedList::new));
}

С другим классом данных:

@Data
class AggregateQueryEntity {

    private final Timestamp timestamp;
    private final EventLevel eventLevel;
    private final long count;
}

Ответы [ 2 ]

0 голосов
/ 23 мая 2019

Если вы используете timestamp with time zone, оно должно работать так, как вы ожидаете, потому что добавление 1 дня иногда добавляет 23 или 25 часов:

SHOW timezone;

   TimeZone    
---------------
 Europe/Vienna
(1 row)

SELECT * from generate_series(
                 TIMESTAMP WITH TIME ZONE '2019-03-28',
                 TIMESTAMP WITH TIME ZONE '2019-04-05',
                 INTERVAL '1' DAY
              );

    generate_series     
------------------------
 2019-03-28 00:00:00+01
 2019-03-29 00:00:00+01
 2019-03-30 00:00:00+01
 2019-03-31 00:00:00+01
 2019-04-01 00:00:00+02
 2019-04-02 00:00:00+02
 2019-04-03 00:00:00+02
 2019-04-04 00:00:00+02
 2019-04-05 00:00:00+02
(9 rows)

Как вы можете видеть, это зависит от текущей настройкииз timezone, что соответствует арифметике даты, выполняемой generate_series.

Если вы хотите использовать это, вам придется настроить параметр для каждого запроса.К счастью, это не сложно:

BEGIN;  -- a transaction
SET LOCAL timezone = 'whatever';  -- for the transaction only
SELECT /* your query */;
COMMIT;
0 голосов
/ 23 мая 2019

Достаточно простое решение будет исправлять его с помощью Java-кода, а не извлекать его непосредственно из SQL - не говоря уже о том, что это невозможно, но, возможно, довольно сложно.ниже приведен Java-код, который вы можете исправить. Как и простой запрос, вы получите дату, время и часовой пояс из результата SQL независимо от разницы часовых поясов. Например,

date 1: 00:00
date 2: 00:00
date 3: 00:00
(now a DST change happens)
date 3: 23:00
date 4: 23:00

В вашем случае переход на летнее время происходит между датами 3и дата 4. Рассмотрите дату 3 как oldDate, а дату 4 как newDate переменную в коде ниже Java.Шаг 1: Получить часовой пояс из обеих дат с помощью newDate.getTimezoneOffset() и oldDate.getTimezoneOffset()

TimeZone timezone = TimeZone.getDefault();
{
// compare this 2 timezone to see if they are in different timezone that way you will see if Daylight saving changes took place. i.e. (GMT and BST (+1) )
// calculation will only be done if timezones are different
if(!(oldDate.getTimezoneOffset() == newDate.getTimezoneOffset()) ){
//save time to modify it later on
final long newTime = newDate.getTime(); 
//this function will check time difference caused by DST
long timediff = checkTimeZoneDiff(oldDate, newDate)

//update newDate (date 4) based on difference found.
newDate = new Date(time+timediff);
}


private long checkTimeZoneDiff(newDate,oldDate){
if(timezone.inDaylightTime(oldDate))
   // this will add +1 hour
    return timezone.getDSTSavings();
else if (timezone.inDaylightTime(newDate)){
   /* this will remove -1 hour, in your case code should go through this bit resulting in 24 hour correct day*/
    return -timezone.getDSTSavings()
else
    return 0;
}

Надеюсь, что это имеет смысл, вы будете добавлять timediff к newDate (дата 4).И продолжить тот же процесс для всех остальных.См. Пузырьковый алгоритм для проверки значений в этой последовательности.

...