Spring Data JPA

Ghid complet pentru persistența datelor în aplicații Spring

Ce este Spring Data JPA?

Spring Data JPA este un modul din ecosistemul Spring care simplifică dramatic lucrul cu baze de date relaționale. Elimină codul boilerplate și oferă o abstracție puternică peste JPA (Java Persistence API).

Aplicație
Spring Data JPA
JPA/Hibernate
JDBC
Database

Avantaje principale

Configurare

Dependențe Maven

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

application.properties

# Conexiune bază de date
spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=root
spring.datasource.password=secret

# Hibernate DDL (create, update, validate, none)
spring.jpa.hibernate.ddl-auto=update

# Afișare SQL în consolă (pentru debug)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Dialect specific bazei de date
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
⚠️ Atenție În producție, folosește ddl-auto=validate sau none. Migrările de schemă ar trebui gestionate cu Flyway sau Liquibase.

Entități JPA

O entitate reprezintă un tabel din baza de date. Fiecare instanță a clasei corespunde unui rând din tabel.

Entitate de bază

@Entity
@Table(name = "users")
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, length = 100)
    private String name;
    
    @Column(unique = true)
    private String email;
    
    @Enumerated(EnumType.STRING)
    private Status status;
    
    // getters, setters, constructors
}

Adnotări esențiale

Adnotare Descriere
@Entity Marchează clasa ca entitate JPA
@Table Specifică numele tabelului (opțional)
@Id Definește cheia primară
@GeneratedValue Strategie de generare ID (IDENTITY, SEQUENCE, AUTO, UUID)
@Column Configurare coloană: name, nullable, unique, length
@Transient Câmpul nu va fi persistat în DB
@Lob Large Object (BLOB/CLOB)
@Temporal Pentru Date/Calendar (DATE, TIME, TIMESTAMP)

Strategii de generare ID

// Auto-increment (MySQL, PostgreSQL serial)
@GeneratedValue(strategy = GenerationType.IDENTITY)

// Sequence (PostgreSQL, Oracle)
@GeneratedValue(strategy = GenerationType.SEQUENCE, 
               generator = "user_seq")
@SequenceGenerator(name = "user_seq", sequenceName = "user_sequence")

// UUID (pentru ID-uri distribuite)
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

Embedded Objects

@Embeddable
public class Address {
    private String street;
    private String city;
    private String zipCode;
}

@Entity
public class User {
    @Embedded
    private Address address;
    
    // Override column names if needed
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "street", 
            column = @Column(name = "work_street"))
    })
    private Address workAddress;
}

Repository Pattern

Repository-ul este interfața principală pentru operații CRUD. Spring Data JPA generează implementarea automat la runtime.

Ierarhia Repository

Repository<T, ID>
CrudRepository
ListCrudRepository
JpaRepository

Definire Repository

public interface UserRepository extends JpaRepository<User, Long> {
    // Metodele CRUD sunt moștenite automat
}

Metode moștenite din JpaRepository

// Save (insert sau update)
User saved = repository.save(user);
List<User> savedAll = repository.saveAll(users);

// Find
Optional<User> user = repository.findById(1L);
List<User> all = repository.findAll();
List<User> byIds = repository.findAllById(List.of(1L, 2L));

// Exists / Count
boolean exists = repository.existsById(1L);
long count = repository.count();

// Delete
repository.deleteById(1L);
repository.delete(user);
repository.deleteAll();
repository.deleteAllById(List.of(1L, 2L));

// JPA specific
repository.flush();
repository.saveAndFlush(user);
User ref = repository.getReferenceById(1L); // lazy proxy
💡 getReferenceById vs findById getReferenceById returnează un proxy lazy (nu execută query). Util când ai nevoie doar de referință pentru relații. findById face SELECT imediat.

Query Methods (Derived Queries)

Spring Data JPA generează query-uri automat pe baza numelui metodei. Aceasta este una dintre cele mai puternice caracteristici.

Sintaxă

public interface UserRepository extends JpaRepository<User, Long> {

    // findBy + PropertyName
    Optional<User> findByEmail(String email);
    List<User> findByStatus(Status status);
    
    // Multiple conditions (And, Or)
    List<User> findByNameAndStatus(String name, Status status);
    List<User> findByStatusOrRole(Status status, String role);
    
    // Comparisons
    List<User> findByAgeGreaterThan(int age);
    List<User> findByAgeBetween(int min, int max);
    List<User> findByCreatedAtAfter(LocalDateTime date);
    
    // String matching
    List<User> findByNameContaining(String part);
    List<User> findByNameStartingWith(String prefix);
    List<User> findByEmailEndingWith(String domain);
    List<User> findByNameIgnoreCase(String name);
    
    // Null checks
    List<User> findByDeletedAtIsNull();
    List<User> findByAvatarIsNotNull();
    
    // Collections
    List<User> findByStatusIn(Collection<Status> statuses);
    List<User> findByRoleNotIn(List<String> roles);
    
    // Ordering
    List<User> findByStatusOrderByCreatedAtDesc(Status status);
    
    // Limiting results
    Optional<User> findFirstByOrderByCreatedAtDesc();
    List<User> findTop10ByStatus(Status status);
    
    // Count / Exists / Delete
    long countByStatus(Status status);
    boolean existsByEmail(String email);
    void deleteByStatus(Status status);
    
    // Nested properties (navigare prin relații)
    List<User> findByAddressCity(String city);
    List<User> findByDepartmentCompanyName(String companyName);
}

Cuvinte cheie disponibile

Keyword SQL echivalent
AndAND
OrOR
Is, Equals=
BetweenBETWEEN x AND y
LessThan<
LessThanEqual<=
GreaterThan>
GreaterThanEqual>=
IsNull, NullIS NULL
IsNotNull, NotNullIS NOT NULL
LikeLIKE
NotLikeNOT LIKE
StartingWithLIKE 'x%'
EndingWithLIKE '%x'
ContainingLIKE '%x%'
OrderByORDER BY
Not<>
InIN (...)
NotInNOT IN (...)
True= TRUE
False= FALSE
IgnoreCaseUPPER(x) = UPPER(?)

JPQL și Native Queries

Pentru query-uri complexe ce nu pot fi exprimate prin numele metodei, folosești @Query.

JPQL (Java Persistence Query Language)

public interface UserRepository extends JpaRepository<User, Long> {

    // Parametri poziționali (?1, ?2)
    @Query("SELECT u FROM User u WHERE u.status = ?1")
    List<User> findByStatusJpql(Status status);
    
    // Parametri numiți (:name)
    @Query("SELECT u FROM User u WHERE u.email = :email AND u.status = :status")
    Optional<User> findByEmailAndStatus(
        @Param("email") String email, 
        @Param("status") Status status
    );
    
    // JOIN și condiții complexe
    @Query("SELECT u FROM User u JOIN u.orders o WHERE o.total > :minTotal")
    List<User> findUsersWithOrdersAbove(@Param("minTotal") BigDecimal minTotal);
    
    // Agregări
    @Query("SELECT COUNT(u) FROM User u WHERE u.status = :status")
    long countByStatusJpql(@Param("status") Status status);
    
    // Update/Delete cu @Modifying
    @Modifying
    @Query("UPDATE User u SET u.status = :status WHERE u.lastLogin < :date")
    int deactivateInactiveUsers(
        @Param("status") Status status, 
        @Param("date") LocalDateTime date
    );
    
    @Modifying
    @Query("DELETE FROM User u WHERE u.status = :status")
    void deleteByStatusJpql(@Param("status") Status status);
}
⚠️ @Modifying Query-urile UPDATE și DELETE necesită @Modifying și trebuie executate într-o tranzacție. Adaugă @Transactional la service sau la metodă.

Native SQL Queries

// Query SQL nativ
@Query(
    value = "SELECT * FROM users WHERE status = :status LIMIT :limit",
    nativeQuery = true
)
List<User> findTopNativeByStatus(
    @Param("status") String status, 
    @Param("limit") int limit
);

// Native cu paginare
@Query(
    value = "SELECT * FROM users WHERE status = ?1",
    countQuery = "SELECT COUNT(*) FROM users WHERE status = ?1",
    nativeQuery = true
)
Page<User> findByStatusNative(String status, Pageable pageable);

SpEL în Queries

// #{#entityName} se înlocuiește cu numele entității
@Query("SELECT e FROM #{#entityName} e WHERE e.status = ?1")
List<User> findByStatus(Status status);

// Util pentru repository-uri generice/moștenite

Relații între Entități

@OneToMany / @ManyToOne

@Entity
public class Department {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "department", cascade = CascadeType.ALL)
    private List<Employee> employees = new ArrayList<>();
}

@Entity
public class Employee {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id")
    private Department department;
}

@OneToOne

@Entity
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "profile_id")
    private UserProfile profile;
}

@Entity
public class UserProfile {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String bio;
    
    @OneToOne(mappedBy = "profile")
    private User user;
}

@ManyToMany

@Entity
public class Student {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToMany
    @JoinTable(
        name = "student_course",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private Set<Course> courses = new HashSet<>();
}

@Entity
public class Course {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToMany(mappedBy = "courses")
    private Set<Student> students = new HashSet<>();
}

Fetch Types și N+1 Problem

💡 FetchType

LAZY (default pentru colecții) — încarcă doar când accesezi
EAGER — încarcă imediat cu entitatea părinte

// Evitarea N+1 cu JOIN FETCH
@Query("SELECT d FROM Department d JOIN FETCH d.employees WHERE d.id = :id")
Optional<Department> findByIdWithEmployees(@Param("id") Long id);

// Sau cu @EntityGraph
@EntityGraph(attributePaths = {"employees"})
Optional<Department> findById(Long id);

Cascade Types

Tip Descriere
PERSISTsave() se propagă la entitățile asociate
MERGEupdate se propagă
REMOVEdelete se propagă
REFRESHrefresh se propagă
DETACHdetach se propagă
ALLtoate operațiile

orphanRemoval

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children;

// Când scoți un child din listă, va fi șters din DB
parent.getChildren().remove(child); // → DELETE

Paginare și Sortare

Pageable

// În Repository
Page<User> findByStatus(Status status, Pageable pageable);
Slice<User> findSliceByStatus(Status status, Pageable pageable);
List<User> findAllBy(Pageable pageable);

// În Service/Controller
Pageable pageable = PageRequest.of(0, 10); // pagina 0, 10 elemente
Page<User> page = userRepository.findByStatus(Status.ACTIVE, pageable);

// Accesare rezultate
List<User> users = page.getContent();
long total = page.getTotalElements();
int totalPages = page.getTotalPages();
boolean hasNext = page.hasNext();
boolean isFirst = page.isFirst();

Sort

// Sortare simplă
Sort sort = Sort.by("name");
Sort sortDesc = Sort.by(Sort.Direction.DESC, "createdAt");

// Sortare multiplă
Sort sort = Sort.by("status").and(Sort.by("name").descending());

// Cu PageRequest
Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());

// În metoda repository
List<User> findByStatus(Status status, Sort sort);

Page vs Slice

Page Slice
Execută COUNT query suplimentar Nu face COUNT
Știi totalul de elemente/pagini Știi doar dacă există next
Mai lent pentru tabele mari Mai rapid, ideal pentru infinite scroll

Tranzacții

@Transactional

@Service
public class UserService {

    // Tranzacție read-write (default)
    @Transactional
    public void transferCredits(Long fromId, Long toId, int amount) {
        User from = userRepository.findById(fromId).orElseThrow();
        User to = userRepository.findById(toId).orElseThrow();
        
        from.setCredits(from.getCredits() - amount);
        to.setCredits(to.getCredits() + amount);
        // Dacă apare excepție, totul se face rollback
    }
    
    // Read-only (optimizat, fără dirty checking)
    @Transactional(readOnly = true)
    public List<User> getAllActive() {
        return userRepository.findByStatus(Status.ACTIVE);
    }
    
    // Cu timeout și rollback rules
    @Transactional(
        timeout = 30,
        rollbackFor = BusinessException.class,
        noRollbackFor = EmailException.class
    )
    public void complexOperation() { /* ... */ }
}

Propagation

Tip Comportament
REQUIREDFolosește tranzacția existentă sau creează una nouă (default)
REQUIRES_NEWÎntotdeauna creează tranzacție nouă
NESTEDSavepoint în tranzacția curentă
SUPPORTSParticipă dacă există, altfel non-transactional
NOT_SUPPORTEDSuspendă tranzacția existentă
MANDATORYTrebuie să existe tranzacție, altfel excepție
NEVERNu trebuie să existe tranzacție
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAction(String action) {
    // Se salvează chiar dacă tranzacția părinte face rollback
    auditRepository.save(new AuditLog(action));
}
⚠️ Proxy Limitation @Transactional funcționează doar când metoda e apelată din exterior (prin proxy). Apeluri interne din aceeași clasă nu trec prin proxy!

Auditing

Spring Data JPA poate popula automat câmpuri precum createdAt, updatedBy.

Configurare

// Activare auditing
@Configuration
@EnableJpaAuditing
public class JpaConfig {
    
    // Optional: pentru createdBy/modifiedBy
    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> Optional.ofNullable(
            SecurityContextHolder.getContext()
                .getAuthentication()
                .getName()
        );
    }
}

Entitate cu Auditing

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String modifiedBy;
    
    // getters
}

@Entity
public class User extends BaseEntity {
    // câmpurile de audit sunt moștenite
}

Callbacks JPA

@Entity
public class User {
    
    @PrePersist
    protected void onCreate() {
        this.slug = generateSlug(this.name);
    }
    
    @PreUpdate
    protected void onUpdate() {
        this.version++;
    }
    
    @PostLoad
    protected void onLoad() {
        // după încărcare din DB
    }
}

Projections

Projections permit selectarea doar a câmpurilor necesare, evitând încărcarea entității complete.

Interface Projection (Closed)

// Definire projection
public interface UserSummary {
    Long getId();
    String getName();
    String getEmail();
}

// În repository
List<UserSummary> findByStatus(Status status);

// Spring generează: SELECT id, name, email FROM users WHERE status = ?

Open Projection (cu SpEL)

public interface UserView {
    String getName();
    
    @Value("#{target.firstName + ' ' + target.lastName}")
    String getFullName();
    
    @Value("#{target.email.split('@')[1]}")
    String getEmailDomain();
}

Class-based Projection (DTO)

// DTO class
public record UserDto(Long id, String name, String email) {}

// În repository - cu JPQL
@Query("SELECT new com.example.dto.UserDto(u.id, u.name, u.email) FROM User u")
List<UserDto> findAllAsDto();

// Sau automat (Spring Data)
List<UserDto> findByStatus(Status status);

Dynamic Projections

// Generic method
<T> List<T> findByStatus(Status status, Class<T> type);

// Usage
List<UserSummary> summaries = repo.findByStatus(Status.ACTIVE, UserSummary.class);
List<UserDto> dtos = repo.findByStatus(Status.ACTIVE, UserDto.class);
List<User> entities = repo.findByStatus(Status.ACTIVE, User.class);

Specifications (Criteria API)

Specifications permit construirea dinamică de query-uri complexe. Ideale pentru filtre de căutare.

Setup

// Repository extinde JpaSpecificationExecutor
public interface UserRepository extends 
        JpaRepository<User, Long>,
        JpaSpecificationExecutor<User> {
}

Definire Specifications

public class UserSpecifications {

    public static Specification<User> hasStatus(Status status) {
        return (root, query, cb) -> 
            cb.equal(root.get("status"), status);
    }
    
    public static Specification<User> nameContains(String name) {
        return (root, query, cb) -> 
            cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
    }
    
    public static Specification<User> createdAfter(LocalDateTime date) {
        return (root, query, cb) -> 
            cb.greaterThan(root.get("createdAt"), date);
    }
    
    public static Specification<User> inDepartment(Long deptId) {
        return (root, query, cb) -> 
            cb.equal(root.get("department").get("id"), deptId);
    }
}

Combinare Specifications

// În service
public List<User> search(UserSearchCriteria criteria) {
    Specification<User> spec = Specification.where(null);
    
    if (criteria.getStatus() != null) {
        spec = spec.and(UserSpecifications.hasStatus(criteria.getStatus()));
    }
    
    if (criteria.getName() != null) {
        spec = spec.and(UserSpecifications.nameContains(criteria.getName()));
    }
    
    if (criteria.getFromDate() != null) {
        spec = spec.and(UserSpecifications.createdAfter(criteria.getFromDate()));
    }
    
    return userRepository.findAll(spec, PageRequest.of(0, 20));
}

// Sau fluent
List<User> users = userRepository.findAll(
    hasStatus(Status.ACTIVE)
        .and(nameContains("john"))
        .or(createdAfter(LocalDateTime.now().minusDays(7)))
);
✅ Best Practice Specifications sunt ideale pentru endpoint-uri de căutare cu filtre opționale multiple. Evită să ai zeci de metode în repository pentru fiecare combinație de filtre.

Query by Example (QBE)

Metodă simplă de query bazată pe o entitate "probe".

// Creează probe (exemplu)
User probe = new User();
probe.setStatus(Status.ACTIVE);
probe.setRole("ADMIN");

// ExampleMatcher pentru customizare
ExampleMatcher matcher = ExampleMatcher.matching()
    .withIgnoreCase()
    .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING)
    .withIgnorePaths("id", "createdAt");

Example<User> example = Example.of(probe, matcher);

// Query
List<User> users = userRepository.findAll(example);
long count = userRepository.count(example);
boolean exists = userRepository.exists(example);