← Kursa Dön
📄 Text · 20 min

@Query ile JPQL ve Native SQL

Giriş

Spring Data JPA'nın derived query method'ları (findByNameAndAgeGreaterThan gibi) basit sorgular için harikadır. Ama gerçek dünyada sorgular basit kalmaz. JOIN'ler, alt sorgular, aggregate fonksiyonlar, veritabanına özel optimizasyonlar... Bir noktada method adı o kadar uzar ki okunmaz hale gelir:

// Bu method adı gerçekten okunabilir mi?
List<User> findByActiveAndRoleAndAgeGreaterThanEqualAndCreatedAtAfterOrderByNameAsc(
    boolean active, Role role, int age, LocalDateTime date);

İşte @Query annotation'ı burada devreye girer. JPQL (Java Persistence Query Language) veya Native SQL yazarak istediğiniz karmaşıklıkta sorgular oluşturabilirsiniz.

Gerçek Dünya Analojisi

Bir restoranda düşünün. Menüdeki yemekleri sipariş etmek kolaydır (derived query methods = menü). Ama "Şef, bana az pişmiş biftek ama yanında patates yerine pilav, sos olarak da özel karışımınızdan" demek istiyorsanız (karmaşık sorgu), menü yetmez — şefle doğrudan konuşmanız gerekir. @Query, şefle doğrudan konuşmanızı sağlayan kanaldır.

Ne Zaman @Query Kullanmalı?

SenaryoTercih
Basit WHERE koşullarıDerived query methods
JOIN gerektiren sorgular@Query JPQL
Aggregate (SUM, AVG, COUNT)@Query JPQL
Veritabanına özel fonksiyonlar@Query Native SQL
Full-text search@Query Native SQL
Bulk UPDATE/DELETE@Query + @Modifying

JPQL — Java Persistence Query Language

JPQL, SQL'e çok benzer ama kritik bir fark vardır: tablo ve sütun yerine entity ve field adları kullanılır. Yani users tablosu değil User entity'si, user_name sütunu değil name field'ı.

JPQL vs SQL Karşılaştırma

-- SQL (tablo ve sütun adları)
SELECT u.user_name, u.email_address
FROM users u
WHERE u.is_active = true AND u.user_age > 25;

-- JPQL (entity ve field adları)
SELECT u.name, u.email
FROM User u
WHERE u.active = true AND u.age > 25;

Temel JPQL Sorguları

public interface UserRepository extends JpaRepository<User, Long> {

    // Named parameter — :paramName şeklinde
    @Query("SELECT u FROM User u WHERE u.email = :email")
    Optional<User> findByEmailAddress(@Param("email") String email);

    // Positional parameter — ?1, ?2 şeklinde (sıra numarası)
    @Query("SELECT u FROM User u WHERE u.name = ?1 AND u.age > ?2")
    List<User> findByNameAndMinAge(String name, int minAge);

    // LIKE sorgusu — case-insensitive arama
    @Query("SELECT u FROM User u WHERE LOWER(u.name) LIKE LOWER(CONCAT('%', :keyword, '%'))")
    List<User> searchByName(@Param("keyword") String keyword);

    // Birden fazla koşul
    @Query("""
        SELECT u FROM User u
        WHERE u.active = true
        AND u.role = :role
        AND u.age BETWEEN :minAge AND :maxAge
        ORDER BY u.name ASC
        """)
    List<User> findActiveUsersByRoleAndAge(
            @Param("role") Role role,
            @Param("minAge") int minAge,
            @Param("maxAge") int maxAge
    );

    // IN sorgusu
    @Query("SELECT u FROM User u WHERE u.role IN :roles AND u.active = true")
    List<User> findActiveUsersByRoles(@Param("roles") Collection<Role> roles);

    // IS NULL / IS NOT NULL
    @Query("SELECT u FROM User u WHERE u.deletedAt IS NULL")
    List<User> findNonDeletedUsers();
}

💡 İpucu: Named parameter (:email) tercih edin. Positional parameter (?1) metot parametrelerinin sırasına bağlıdır — parametre sırası değişirse sorgu bozulur. Named parameter ise parametre adına bağlıdır, sıra önemli değildir.

JOIN Sorguları

public interface UserRepository extends JpaRepository<User, Long> {

    // INNER JOIN — Siparişi olan kullanıcılar
    @Query("SELECT u FROM User u JOIN u.orders o WHERE o.totalAmount > :amount")
    List<User> findUsersWithLargeOrders(@Param("amount") BigDecimal amount);

    // LEFT JOIN — Tüm kullanıcılar (siparişi olmayanlar dahil)
    @Query("SELECT u FROM User u LEFT JOIN u.orders o WHERE o IS NULL")
    List<User> findUsersWithNoOrders();

    // JOIN FETCH — N+1 problemini çözer (eager loading)
    @Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
    Optional<User> findByIdWithOrders(@Param("id") Long id);

    // Çoklu JOIN FETCH
    @Query("""
        SELECT DISTINCT u FROM User u
        JOIN FETCH u.orders o
        JOIN FETCH o.orderItems
        WHERE u.active = true
        """)
    List<User> findActiveUsersWithOrderDetails();
}

⚠️ Dikkat: JOIN FETCH ile birden fazla collection'ı aynı anda fetch etmeyin! Hibernate MultipleBagFetchException fırlatır. Çözüm: birini Set olarak tanımlayın veya ayrı sorgularda fetch edin.

Aggregate Fonksiyonlar

public interface OrderRepository extends JpaRepository<Order, Long> {

    // COUNT
    @Query("SELECT COUNT(o) FROM Order o WHERE o.status = :status")
    long countByStatus(@Param("status") OrderStatus status);

    // SUM
    @Query("SELECT SUM(o.totalAmount) FROM Order o WHERE o.user.id = :userId")
    BigDecimal calculateTotalSpentByUser(@Param("userId") Long userId);

    // AVG
    @Query("SELECT AVG(o.totalAmount) FROM Order o WHERE o.createdAt > :since")
    Double calculateAverageOrderAmount(@Param("since") LocalDateTime since);

    // MIN, MAX
    @Query("SELECT MIN(o.totalAmount), MAX(o.totalAmount) FROM Order o")
    Object[] findOrderAmountRange();

    // GROUP BY
    @Query("SELECT o.status, COUNT(o) FROM Order o GROUP BY o.status")
    List<Object[]> countOrdersByStatus();

    // GROUP BY + HAVING
    @Query("""
        SELECT u.name, COUNT(o), SUM(o.totalAmount)
        FROM User u JOIN u.orders o
        GROUP BY u.name
        HAVING COUNT(o) >= :minOrders
        ORDER BY SUM(o.totalAmount) DESC
        """)
    List<Object[]> findTopCustomers(@Param("minOrders") long minOrders);
}

DTO Projection — Temiz Veri Dönüşümü

Object[] döndürmek yerine doğrudan DTO'ya map edebilirsiniz:

// DTO sınıfı — record veya class olabilir
public record UserSummary(String name, String email, Role role) {}

public interface UserRepository extends JpaRepository<User, Long> {

    // Constructor expression ile DTO dönüşümü
    @Query("""
        SELECT new com.example.dto.UserSummary(u.name, u.email, u.role)
        FROM User u
        WHERE u.active = true
        """)
    List<UserSummary> findActiveUserSummaries();
}

⚠️ Dikkat: new ifadesinde tam paket adı (fully qualified name) kullanmanız zorunludur. new UserSummary(...) yazmak yetmez, new com.example.dto.UserSummary(...) olmalıdır.

Interface-based projection alternatifi (daha pratik):

// Interface projection — getter metotları tanımlayın
public interface UserSummaryView {
    String getName();
    String getEmail();
    Role getRole();
}

public interface UserRepository extends JpaRepository<User, Long> {

    @Query("SELECT u FROM User u WHERE u.active = true")
    List<UserSummaryView> findActiveUserProjections();
}

Native SQL — Veritabanına Özel Sorgular

JPQL yetmediğinde Native SQL kullanırsınız. nativeQuery = true parametresi ile aktifleşir:

public interface UserRepository extends JpaRepository<User, Long> {

    // Temel native query
    @Query(value = "SELECT * FROM users WHERE email = :email", nativeQuery = true)
    Optional<User> findByEmailNative(@Param("email") String email);

    // MySQL full-text search
    @Query(value = """
        SELECT * FROM users
        WHERE MATCH(name, bio) AGAINST(:term IN BOOLEAN MODE)
        """, nativeQuery = true)
    List<User> fullTextSearch(@Param("term") String term);

    // PostgreSQL JSON sorgusu
    @Query(value = """
        SELECT * FROM users
        WHERE preferences->>'theme' = :theme
        """, nativeQuery = true)
    List<User> findByPreferenceTheme(@Param("theme") String theme);

    // Window functions
    @Query(value = """
        SELECT u.*, RANK() OVER (ORDER BY u.score DESC) as user_rank
        FROM users u
        WHERE u.active = true
        """, nativeQuery = true)
    List<Object[]> getUsersWithRank();

    // CTE (Common Table Expression) — PostgreSQL
    @Query(value = """
        WITH active_users AS (
            SELECT * FROM users WHERE active = true
        )
        SELECT au.*, COUNT(o.id) as order_count
        FROM active_users au
        LEFT JOIN orders o ON o.user_id = au.id
        GROUP BY au.id
        ORDER BY order_count DESC
        LIMIT :limit
        """, nativeQuery = true)
    List<Object[]> findTopActiveUsers(@Param("limit") int limit);
}

Native Query ile Pagination

Native SQL'de Page dönerken countQuery belirtmeniz zorunludur:

@Query(
    value = "SELECT * FROM users WHERE role = :role ORDER BY created_at DESC",
    countQuery = "SELECT COUNT(*) FROM users WHERE role = :role",
    nativeQuery = true
)
Page<User> findByRoleNative(@Param("role") String role, Pageable pageable);

JPQL'de Spring count sorgusunu otomatik türetir, ama native SQL'de veritabanına özel olduğu için türetemez.

JPQL vs Native SQL Karşılaştırma

ÖzellikJPQLNative SQL
Entity/field adları✅ Java field adları❌ Tablo/sütun adları
Veritabanı bağımsız✅ Taşınabilir❌ Veritabanına özel
Otomatik count query✅ Var❌ Manuel belirtmeli
Veritabanı fonksiyonları⚠️ Sınırlı✅ Tam destek
Window functions❌ Yok✅ Var
Full-text search❌ Yok✅ Var
JSON/Array sorgulama❌ Yok✅ Var

💡 İpucu: Önce JPQL ile yazın. JPQL yetersiz kalırsa native SQL'e geçin. Native SQL kullanıyorsanız, veritabanı değişikliğinde bu sorguları güncellemeniz gerekecektir.


@Modifying — UPDATE ve DELETE İşlemleri

Normal @Query sadece SELECT yapar. UPDATE veya DELETE için @Modifying eklenmelidir:

public interface UserRepository extends JpaRepository<User, Long> {

    // Bulk UPDATE — tüm inaktif kullanıcıları deaktif et
    @Modifying
    @Transactional
    @Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :date")
    int deactivateInactiveUsers(@Param("date") LocalDateTime date);

    // Bulk DELETE
    @Modifying
    @Transactional
    @Query("DELETE FROM User u WHERE u.active = false AND u.createdAt < :date")
    int deleteOldInactiveUsers(@Param("date") LocalDateTime date);

    // Spesifik alan güncelleme
    @Modifying
    @Transactional
    @Query("UPDATE User u SET u.role = :newRole WHERE u.id = :userId")
    int updateUserRole(@Param("userId") Long userId, @Param("newRole") Role newRole);

    // Toplu e-posta değişikliği
    @Modifying
    @Transactional
    @Query("UPDATE User u SET u.email = CONCAT(u.email, '.old') WHERE u.active = false")
    int archiveInactiveUserEmails();
}

@Modifying Kuralları

  1. `@Transactional` zorunludur — Yoksa TransactionRequiredException alırsınız

  2. Dönüş tipi: int (etkilenen satır sayısı) veya void

  3. clearAutomatically: Persistence context'i temizler

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("UPDATE User u SET u.active = false WHERE u.id = :id")
int deactivateUser(@Param("id") Long id);

clearAutomatically Neden Önemli?

@Transactional
public void deactivateAndCheck(Long id) {
    // 1. Kullanıcıyı çek → persistence context'e girer
    User user = userRepository.findById(id).orElseThrow();
    System.out.println(user.isActive()); // true

    // 2. Bulk update ile deaktif et
    userRepository.deactivateUser(id);
    // Veritabanında active = false oldu AMA...

    // 3. Tekrar çek — persistence context'ten gelir (STALE DATA!)
    User sameUser = userRepository.findById(id).orElseThrow();
    System.out.println(sameUser.isActive()); // true!!! (eski veri!)

    // clearAutomatically = true ile persistence context temizlenir
    // ve 3. adımda veritabanından taze veri çekilir
}

⚠️ Dikkat: @Modifying sorguları persistence context'i bypass eder (doğrudan SQL çalıştırır). Bu yüzden clearAutomatically = true kullanmak en güvenli yaklaşımdır.


SpEL (Spring Expression Language) ile @Query

public interface BaseRepository<T> extends JpaRepository<T, Long> {

    // #{#entityName} → entity adını dinamik olarak alır
    @Query("SELECT e FROM #{#entityName} e WHERE e.active = true")
    List<T> findAllActive();

    @Modifying
    @Transactional
    @Query("UPDATE #{#entityName} e SET e.active = false WHERE e.id = :id")
    int softDelete(@Param("id") Long id);
}

// UserRepository artık findAllActive() ve softDelete() metotlarına sahip
public interface UserRepository extends BaseRepository<User> { }

// ProductRepository da aynı metotlara sahip
public interface ProductRepository extends BaseRepository<Product> { }

Bu pattern, ortak sorguları tekrar yazmadan tüm repository'lerde kullanmanızı sağlar.


Bütünleşik Gerçek Dünya Örneği: Raporlama Servisi

// ===== Repository =====
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Aylık gelir raporu
    @Query("""
        SELECT new com.example.dto.MonthlyRevenue(
            YEAR(o.createdAt), MONTH(o.createdAt), SUM(o.totalAmount), COUNT(o)
        )
        FROM Order o
        WHERE o.status = 'COMPLETED'
        AND o.createdAt BETWEEN :startDate AND :endDate
        GROUP BY YEAR(o.createdAt), MONTH(o.createdAt)
        ORDER BY YEAR(o.createdAt) DESC, MONTH(o.createdAt) DESC
        """)
    List<MonthlyRevenue> getMonthlyRevenue(
        @Param("startDate") LocalDateTime startDate,
        @Param("endDate") LocalDateTime endDate
    );

    // En çok satan ürünler (Native SQL — window function)
    @Query(value = """
        SELECT p.name, SUM(oi.quantity) as total_sold,
               SUM(oi.quantity * oi.unit_price) as total_revenue,
               RANK() OVER (ORDER BY SUM(oi.quantity) DESC) as sales_rank
        FROM order_items oi
        JOIN products p ON p.id = oi.product_id
        JOIN orders o ON o.id = oi.order_id
        WHERE o.status = 'COMPLETED'
        AND o.created_at >= :since
        GROUP BY p.id, p.name
        ORDER BY total_sold DESC
        LIMIT :limit
        """, nativeQuery = true)
    List<Object[]> findTopSellingProducts(
        @Param("since") LocalDateTime since,
        @Param("limit") int limit
    );

    // İptal edilmiş siparişleri temizle
    @Modifying
    @Transactional
    @Query("""
        DELETE FROM Order o
        WHERE o.status = 'CANCELLED'
        AND o.createdAt < :cutoffDate
        """)
    int cleanupCancelledOrders(@Param("cutoffDate") LocalDateTime cutoffDate);
}

// ===== DTO =====
public record MonthlyRevenue(int year, int month, BigDecimal totalRevenue, long orderCount) {}

// ===== Service =====
@Service
@RequiredArgsConstructor
public class ReportService {
    private final OrderRepository orderRepository;

    public List<MonthlyRevenue> getYearlyReport(int year) {
        LocalDateTime start = LocalDateTime.of(year, 1, 1, 0, 0);
        LocalDateTime end = LocalDateTime.of(year, 12, 31, 23, 59, 59);
        return orderRepository.getMonthlyRevenue(start, end);
    }

    @Transactional
    public int cleanupOldCancelledOrders(int daysOld) {
        LocalDateTime cutoff = LocalDateTime.now().minusDays(daysOld);
        return orderRepository.cleanupCancelledOrders(cutoff);
    }
}

Yaygın Hatalar ve Çözümleri

Hata 1: JPQL'de Tablo Adı Kullanmak

// ❌ YANLIŞ — "users" tablo adı, JPQL entity adı kullanır
@Query("SELECT u FROM users u WHERE u.email = :email")

// ✅ DOĞRU — "User" entity sınıfının adı
@Query("SELECT u FROM User u WHERE u.email = :email")

Hata 2: @Modifying Olmadan UPDATE/DELETE

// ❌ YANLIŞ — @Modifying yok
@Query("UPDATE User u SET u.active = false WHERE u.id = :id")
int deactivate(@Param("id") Long id);
// Hata: "Not supported for DML operations"

// ✅ DOĞRU
@Modifying
@Transactional
@Query("UPDATE User u SET u.active = false WHERE u.id = :id")
int deactivate(@Param("id") Long id);

Hata 3: @Transactional Unutmak

// ❌ @Transactional yok
@Modifying
@Query("DELETE FROM User u WHERE u.active = false")
int deleteInactive();
// TransactionRequiredException!

// ✅ DOĞRU — Service veya Repository seviyesinde @Transactional
@Modifying
@Transactional
@Query("DELETE FROM User u WHERE u.active = false")
int deleteInactive();

Hata 4: DTO Constructor Projection'da Paket Adı Unutmak

// ❌ YANLIŞ — Sadece sınıf adı
@Query("SELECT new UserSummary(u.name, u.email) FROM User u")

// ✅ DOĞRU — Tam paket adı
@Query("SELECT new com.example.dto.UserSummary(u.name, u.email) FROM User u")

Hata 5: Native Query'de Entity Field Adı Kullanmak

// ❌ YANLIŞ — "createdAt" Java field adı, native SQL'de sütun adı kullanılır
@Query(value = "SELECT * FROM users WHERE createdAt > :date", nativeQuery = true)
// SQL hatası: Unknown column 'createdAt'

// ✅ DOĞRU — Gerçek sütun adı
@Query(value = "SELECT * FROM users WHERE created_at > :date", nativeQuery = true)

Özet

  • Derived methods basit sorgular için, @Query JPQL karmaşık sorgular için, nativeQuery=true veritabanına özel sorgular için kullanın

  • JPQL entity ve field adları, native SQL tablo ve sütun adları kullanır

  • Named parameter (:email) tercih edin, positional parameter (?1) sıraya bağımlıdır

  • UPDATE/DELETE için @Modifying + @Transactional zorunludur — dönüş tipi int veya void

  • DTO projection ile sadece ihtiyaç duyduğunuz alanları çekin — tam entity çekmekten daha performanslı

  • Native SQL ile Page dönerken countQuery belirtmeyi unutmayın

  • clearAutomatically = true ile persistence context tutarsızlığını önleyin


Performans İpuçları

  1. SELECT * yerine sadece gerekli alanları çekin — DTO projection ile ağ trafiğini ve bellek kullanımını azaltın

  2. JOIN FETCH kullanarak N+1 problemini çözün — İlişkili verileri tek sorguda çekin

  3. Native SQL'i son çare olarak kullanın — JPQL yeterliyse native'e geçmeyin, taşınabilirliğinizi koruyun

  4. Bulk UPDATE/DELETE'lerde clearAutomatically = true kullanın — Stale data'dan kaçının

  5. @Param ile named parameter kullanın — Hem okunabilir hem refactor-safe

  6. Karmaşık sorguları test edin — @DataJpaTest ile repository metodlarınızı mutlaka birim testi yazın