Выборка данных за последние 30 дней на Java: JDBC, Spring Data JPA, MyBatis

Мастер-класс по выборке за 30 дней на Java: чистый JDBC, Spring Data JPA и MyBatis. Разбираем таймзоны и параметры, чтобы запросы летали!

В современных приложениях часто требуется выбирать данные, созданные за определённый промежуток времени – например, за последние 30 дней․ Ниже представлен подробный обзор способов реализации такого запроса на Java: от чистого JDBC до популярных фреймворков (Spring Data JPA, MyBatis)․ В статье учтены нюансы работы с датой и временем, часовыми поясами и параметризованными запросами․

Содержание

  1. Подготовка окружения
  2. Работа с датой и временем в Java
  3. Формирование SQL‑запроса
  4. Пример с чистым JDBC
  5. Пример с Spring Data JPA
  6. Пример с MyBatis
  7. Учёт часовых поясов
  8. Тестирование и отладка
  9. Лучшие практики

Получение записей за последние 30 дней – типичная задача для аналитики, дашбордов и систем мониторинга․ Основные требования:

  • Запрос должен быть параметризованным, чтобы избежать SQL‑инъекций․
  • Необходимо корректно работать с типами DATE, TIMESTAMP и DATETIME в базе данных․
  • Желательно использовать текущее время сервера БД или приложения, учитывая возможные различия часовых поясов․

Подготовка окружения

Для примеров понадобится:

  • Java 17+ (можно и более ранние версии, но java․time доступен с Java 8)․
  • Драйвер JDBC для вашей СУБД (например, postgresql-42․7․3․jar или mysql-connector-j-8․0․33․jar)․
  • Опционально: Spring Boot 3․x, Spring Data JPA, MyBatis‑Spring․
  • Тестовая таблица, например:
  • CREATE TABLE events (
     id BIGSERIAL PRIMARY KEY,
     user_id BIGINT NOT NULL,
     event_type VARCHAR(50),
     created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW
    );
    

    Работа с датой и временем в Java

    С Java 8 рекомендуется использовать пакет java;time:

    • Instant – момент времени в UTC․
    • OffsetDateTime или ZonedDateTime – момент с учётом смещения/часового пояса․
    • LocalDateTime – без пояса (используется, если колонка БД TIMESTAMP WITHOUT TIME ZONE)․

    Получить текущий момент и вычесть 30 дней можно так:

    // Текущий момент в UTC
    Instant now = Instant․now;
    // Минус 30 дней
    Instant thirtyDaysAgo = now․minus(java․time․Duration․ofDays(30));
    

    Если нужен конкретный часовой пояс (например, сервер находится в Европе/Москве):

    ZoneId zone = ZoneId․of("Europe/Moscow");
    ZonedDateTime nowZoned = ZonedDateTime․now(zone);
    ZonedDateTime thirtyDaysAgoZoned = nowZoned․minusDays(30);
    

    Формирование SQL‑запроса

    Базовый запрос выглядит так:

    SELECT *
    FROM events
    WHERE created_at >= ? -- нижняя граница (30 дней назад)
     AND created_at < ? -- верхняя граница (сейчас, исключительно)
    ORDER BY created_at DESC;
    

    Мы используем два параметра, чтобы избежать проблем с включением/исключением границы и обеспечить возможность использования индекса по колонке created_at․

    Пример с чистым JDBC

    Ниже показан метод, возвращающий список объектов Event за последние 30 дней․

    import java․sql․;
    import java․time․;
    import java․util․*;
    
    public class EventDaoJdbc {
    
     private final String url;
     private final String user;
     private final String password;
    
     public EventDaoJdbc(String url, String user, String password) {
     this․url = url;
     this․user = user;
     this․password = password;
     }
    
     public List<Event> findLast30Days throws SQLException {
     String sql = """
     SELECT id, user_id, event_type, created_at
     FROM events
     WHERE created_at >= ?
     AND created_at < ?
     ORDER BY created_at DESC
     """;
    
     try (Connection conn = DriverManager․getConnection(url, user, password);
     PreparedStatement ps = conn․prepareStatement(sql)) {
    
     // Вычисляем границы в UTC (можно изменить на нужный часовой пояс)
     Instant now = Instant․now;
     Instant thirtyDaysAgo = now․minus(Duration․ofDays(30));
    
     // Устанавливаем параметры как TIMESTAMP WITH TIME ZONE
     ps․setTimestamp(1, Timestamp․from(thirtyDaysAgo));
     ps․setTimestamp(2, Timestamp․from(now));
    
     try (ResultSet rs = ps․executeQuery) {
     List<Event> list = new ArrayList<>;
     while (rs․next) {
     Event e = new Event;
     e․setId(rs․getLong("id"));
     e․setUserId(rs․getLong("user_id"));
     e․setEventType(rs․getString("event_type"));
     e․setCreatedAt(rs․getTimestamp("created_at")․toInstant);
     list․add(e);
     }
     return list;
     }
     }
     }
    }
    

    Пояснения:

    • Мы используем PreparedStatement – защита от SQL‑инъекций․
    • Параметры передаются как java․sql․Timestamp, построенный из Instant (UTC)․ Если ваша колонка хранит время без пояса, используйте LocalDateTime и Timestamp․valueOf․
    • Запрос использует диапазон [ thirtyDaysAgo , now ) – это гарантирует, что записи с точно текущим моментом не будут дублироваться при повторном вызове в течение одной миллисекунды․

    Пример с Spring Data JPA

    Если проект уже использует Spring Boot и Spring Data JPA, достаточно определить репозиторий с методом, использующим @Query или derivation․

    Сущность

    import jakarta․persistence․*;
    import java․time․Instant;
    
    @Entity
    @Table(name = "events")
    public class Event {
    
     @Id
     @GeneratedValue(strategy = GenerationType․IDENTITY)
     private Long id;
    
     private Long userId;
    
     @Column(name = "event_type")
     private String eventType;
    
     @Column(name = "created_at")
     private Instant createdAt;
    
     // геттеры и сеттеры
    }

    Репозиторий

    import org․springframework․data․jpa․repository․*;
    import org․springframework․data․repository․query․Param;
    import java․time․Instant;
    import java․util․List;
    
    public interface EventRepository extends JpaRepository<Event, Long> {
    
     @Query("SELECT e FROM Event e WHERE e․createdAt >= :start AND e․createdAt < :end")
     List<Event> findByCreatedAtBetween(
     @Param("start") Instant start,
     @Param("end") Instant end);
    }
    

    Сервис

    import org․springframework;stereotype․Service;
    import java․time․*;
    import java․util․List;
    
    @Service
    public class EventService {
    
     private final EventRepository repo;
    
     public EventService(EventRepository repo) {
     this․repo = repo;
     }
    
     public List<Event> getLast30Days {
     Instant now = Instant․now;
     Instant thirtyDaysAgo = now․minus(Duration․ofDays(30));
     return repo․findByCreatedAtBetween(thirtyDaysAgo, now);
     }
    }
    

    Spring Data JPA автоматически преобразует Instant в appropriate SQL‑тип (обычно TIMESTAMP WITH TIME ZONE для PostgreSQL или DATETIME для MySQL)․

    Пример с MyBatis

    MyBatis позволяет писать XML‑мапперы или использовать аннотации․

    Mapper (XML)

    <mapper namespace="com․example․mapper․EventMapper">
    
     <select id="selectLast30Days" resultType="Event">
     SELECT id, user_id, event_type, created_at
     FROM events
     WHERE created_at >= #{startDate}
     AND created_at < #{endDate}
     ORDER BY created_at DESC
     </select>
    
    </mapper>
    

    Java‑интерфейс

    package com․example․mapper;
    
    import org․apache․ibatis․annotations․*;
    import java․time․Instant;
    import java․util․List;
    
    public interface EventMapper {
    
     @Select("""
     SELECT id, user_id, event_type, created_at
     FROM events
     WHERE created_at >= #{startDate}
     AND created_at < #{endDate}
     ORDER BY created_at DESC
     """)
     List<Event> selectLast30Days(@Param("startDate") Instant startDate,
     @Param("endDate") Instant endDate);
    }
    

    Вызов из сервиса

    @Service
    public class EventServiceMyBatis {
    
     private final EventMapper mapper;
    
     public EventServiceMyBatis(EventMapper mapper) {
     this․mapper = mapper;
     }
    
     public List<Event> getLast30Days {
     Instant now = Instant․now;
     Instant thirtyDaysAgo = now․minus(Duration․ofDays(30));
     return mapper․selectLast30Days(thirtyDaysAgo, now);
     }
    }
    

    Учёт часовых поясов

    Неправильная работа с часовыми поясами – частая причина ошибок при выборке за период․

    • Если колонка created_at имеет тип TIMESTAMP WITH TIME ZONE (PostgreSQL, Oracle с TIMESTAMP WITH TIME ZONE), передавайте параметры в виде Instant или OffsetDateTime; JDBC‑драйвер сам выполнит конверсию в нужный часовой пояс БД․
    • Для колонки TIMESTAMP WITHOUT TIME ZONE (MySQL DATETIME, PostgreSQL без WITH TIME ZONE) лучше хранить время в UTC и всегда сравнивать с UTC․ В этом случае используйте Instant и приведите его к Timestamp (как в примере JDBC)․
    • Если требуется работать с локальным временем пользователя (например, «за последние 30 дней по времени пользователя»), сначала переведите момент пользователя в UTC, используя его ZoneId, а затем выполните запрос․

    Тестирование и отладка

    Для unit‑тестов удобно использовать in‑memory БД (H2) с режимом совместимости с вашей СУБД․

    @Test
    void testFindLast30Days_returnsRecentEvents throws Exception {
     // given
     Instant now = Instant․now;
     Instant thirtyDaysAgo = now․minus(Duration․ofDays(30));
     Instant old = now․minus(Duration․ofDays(31));
    
     // вставляем тестовые данные через JdbcTemplate или EntityManager
     jdbc․update("INSERT INTO events (user_id, event_type, created_at) VALUES (?, ?, ?)",
     1L, "TYPE_A", Timestamp․from(old));
     jdbc․update("INSERT INTO events (user_id, event_type, created_at) VALUES (?, ?, ?)",
     2L, "TYPE_B", Timestamp․from(now․minus(Duration․ofDays(15))));
    
     // when
     List<Event> result = dao․findLast30Days;
    
     // then
     assertEquals(1, result․size);
     assertEquals("TYPE_B", result․get(0)․getEventType);
    }
    

    Для проверки корректности работы с часовыми поясами можно установить временную зону JVM:

    TimeZone․setDefault(TimeZone․getTimeZone("America/New_York"));
    

    и убедиться, что результаты не меняются (если используется UTC в базе)․

    Лучшие практики

    • Параметризуйте запрос – никогда не concatenate строки с датами․
    • Индексируйте колонку даты – без индекса по created_at запрос будет сканировать всю таблицу․
    • Выбирайте только нужные колонки – вместо SELECT * явно перечислите поля, чтобы уменьшить объём передаваемых данных․
    • Учитывайте смещение времени – если ваша БД хранит время в UTC, всегда сравнивайте с UTC․
    • Логируйте параметры запроса (в режиме DEBUG) – это упрощает отладку проблем с часовыми поясами․
    • Используйте connection pool (HikariCP, Tomcat JDBC) – избегайте создания нового соединения на каждый запрос․
    • Тестируйте граничные случаи – записи, созданные ровно 30 дней назад и ровно сейчас․

    Получение записей за последние 30 дней в Java – задача, которую можно решить несколькими способами в зависимости от стека технологий:

    • Чистый JDBC даёт полный контроль над запросом и параметрами, подходит для высоконагруженных систем или когда нужен минимальный набор зависимостей․
    • Spring Data JPA упрощает код благодаря автоматическому маппингу и возможности использовать derivation или @Query․
    • MyBatis гибок в написании SQL и удобен, когда требуется сложная динамическая конструкция запроса․

    Ключевой момент – правильно работать с датой и временем, использовать параметризованные запросы и учитывать часовые пояса․ Следуя рекомендациям из этой статьи, вы сможете построить надёжный и производительный механизм выборки данных за последние 30 дней․


    Автор: ваш помощник по программированию․ Дата публикации: ․