# თავი 4: მონაცემთა ბაზებთან მუშაობა --- ## 4.1 მეხსიერებიდან პერსისტენტულ მონაცემებამდე წინა თავებში ჩვენ ავაშენეთ controller-ები, დავარენდერეთ შაბლონები, დავამუშავეთ ფორმების გაგზავნა და შევამოწმეთ მომხმარებლის მიერ შეყვანილი მონაცემები. მაგრამ ყოველ ჯერზე, როდესაც აპლიკაცია გადაიტვირთებოდა, ყველა მონაცემი იკარგებოდა — რადგან ჩვენ მათ მეხსიერებაში ვინახავდით. ნამდვილ ვებ აპლიკაციას სჭირდება პერსისტენტული (მუდმივი) საცავი და ეს თითქმის ყოველთვის ნიშნავს მონაცემთა ბაზას. ეს თავი წარმოგიდგენთ იმ ინსტრუმენტებს, რომლებსაც Spring Boot უზრუნველყოფს რელაციურ მონაცემთა ბაზებთან სამუშაოდ. ჩვენ ვისწავლით, თუ როგორ ავსებს Object-Relational Mapping (ORM) ნაპრალს Java ობიექტებსა და მონაცემთა ბაზის ცხრილებს შორის, როგორ განვსაზღვროთ entity-ები და დავაკავშიროთ (map) ისინი ერთმანეთთან, როგორ შევასრულოთ მონაცემებთან წვდომა Spring Data JPA repository-ების მეშვეობით, როგორ დავწეროთ მორგებული query-ები და როგორ განვახორციელოთ პაგინაცია და სორტირება. ჩვენ ასევე განვიხილავთ ტრანზაქციების მართვას, მონაცემთა ბაზის კონფიგურაციას დეველოპმენტისა და production-ისთვის, მონაცემთა ინიციალიზაციას და სქემის მიგრაციის ინსტრუმენტებს. ამ თავში მონაცემთა ბაზასთან წვდომისთვის გამოიყენება **Spring Data JPA** — Spring-ის აბსტრაქციის ფენა Jakarta Persistence API-ს (JPA) ზემოთ, სადაც Hibernate არის ძირითადი ORM იმპლემენტაცია. --- ## 4.2 შესავალი ORM-სა და JPA-ში როდესაც Java-დან რელაციურ მონაცემთა ბაზასთან მუშაობთ, არსებობს ფუნდამენტური შეუსაბამობა: Java მუშაობს ობიექტებთან და კლასების იერარქიებთან, ხოლო მონაცემთა ბაზები მუშაობენ ცხრილებთან, რიგებთან და უცხოურ გასაღებებთან (foreign keys). **Object-Relational Mapping (ORM)** არის ტექნიკა, რომელიც ამ ნაპრალს ავსებს. იმის ნაცვლად, რომ დაწეროთ უშუალოდ (raw) SQL მონაცემების ჩასასმელად, გასაახლებლად და მოსაძიებლად, თქვენ მუშაობთ Java ობიექტებთან და ORM ფრეიმვორქი კულისებში თარგმნის თქვენს ოპერაციებს შესაბამის SQL-ად. **JPA (Jakarta Persistence API)** არის სპეციფიკაცია, რომელიც განსაზღვრავს, თუ როგორ მუშაობს ORM Java-ში. ის უზრუნველყოფს ანოტაციებისა და ინტერფეისების სტანდარტულ ნაკრებს — `@Entity`, `@Id`, `@Column`, `EntityManager` და ა.შ. — რომელთა მხარდაჭერაც ნებისმიერ შესაბამის იმპლემენტაციას უნდა ჰქონდეს. **Hibernate** არის ყველაზე ფართოდ გამოყენებადი JPA იმპლემენტაცია და სწორედ მას იყენებს Spring Boot ნაგულისხმევად. **Spring Data JPA** დაშენებულია JPA-სა და Hibernate-ზე. ის გამორიცხავს ზედმეტ (boilerplate) კოდს repository-ების იმპლემენტაციების ავტომატურად გენერირებით ინტერფეისის განმარტებებიდან. თქვენ აცხადებთ რა მონაცემები გჭირდებათ; Spring Data JPA ხვდება, როგორ მოიპოვოს ისინი. ### დამოკიდებულებების (Dependencies) დამატება Spring Data JPA-ს გამოსაყენებლად, დაამატეთ starter დამოკიდებულება და მონაცემთა ბაზის დრაივერი თქვენს `pom.xml`-ში: ```xml org.springframework.boot spring-boot-starter-data-jpa com.h2database h2 runtime ``` `spring-boot-starter-data-jpa` დამოკიდებულებას შემოაქვს Hibernate, JPA API, Spring Data JPA და connection pool-ის მართვა (HikariCP). H2 დამოკიდებულება უზრუნველყოფს მსუბუქ, in-memory მონაცემთა ბაზას, რომელიც იდეალურია დეველოპმენტისა და ტესტირებისთვის — არ მოითხოვს ინსტალაციას, არ სჭირდება სერვერის მართვა. --- ## 4.3 Entity-ების განსაზღვრა **Entity** არის Java კლასი, რომელიც ასახულია (mapped) მონაცემთა ბაზის ცხრილზე. entity-ის თითოეული ინსტანსი შეესაბამება ცხრილის ერთ რიგს. კლასის თითოეული ველი შეესაბამება სვეტს. ```java @Entity @Table(name = "products") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 100) private String name; @Column(nullable = false) private Double price; @Column(length = 500) private String description; @Column(name = "created_at") private LocalDateTime createdAt; // Default constructor required by JPA public Product() {} // getters and setters } ``` მოდით დავშალოთ, თუ რა ხდება აქ. `@Entity` აღნიშნავს კლასს როგორც JPA entity-ს — Hibernate მას მართავს და ასახავს მონაცემთა ბაზის ცხრილზე. ამ ანოტაციის გარეშე, კლასი უბრალოდ ჩვეულებრივი Java კლასია. `@Table(name = "products")` განსაზღვრავს მონაცემთა ბაზის ცხრილის სახელს. ეს არასავალდებულოა — თუ გამოტოვებთ, JPA ცხრილის სახელად კლასის სახელს გამოიყენებს. `@Table`-ის ექსპლიციტურად გამოყენება ცხადს ხდის mapping-ს და საშუალებას გაძლევთ დაიცვათ მონაცემთა ბაზის დასახელების კონვენციები (როგორიცაა მრავლობითი რიცხვი, snake_case) Java-ს დასახელების კონვენციებისგან დამოუკიდებლად. `@Id` აღნიშნავს ველს როგორც primary key-ს. ყოველ entity-ს უნდა ჰქონდეს ზუსტად ერთი `@Id` ველი. `@GeneratedValue(strategy = GenerationType.IDENTITY)` ეუბნება JPA-ს, რომ მონაცემთა ბაზა ავტომატურად დააგენერირებს primary key-ს მნიშვნელობებს auto-increment სვეტის გამოყენებით. ეს არის ყველაზე გავრცელებული სტრატეგია MySQL-ისა და PostgreSQL-ისთვის. `@Column` აკონფიგურირებს, თუ როგორ აისახება ველი სვეტზე. შეგიძლიათ მიუთითოთ სვეტის სახელი, დაშვებულია თუ არა null მნიშვნელობები, მისი მაქსიმალური სიგრძე და უნდა იყოს თუ არა ის უნიკალური. თუ გამოტოვებთ `@Column`-ს, JPA მაინც დააკავშირებს ველს — უბრალოდ ნაგულისხმევი პარამეტრებით იხელმძღვანელებს (ველის სახელი როგორც სვეტის სახელი, nullable, სიგრძის შეზღუდვის გარეშე). ნაგულისხმევი (უარგუმენტო) კონსტრუქტორი მოითხოვება JPA-ს მიერ. Hibernate მას იყენებს შიდა პროცესებისთვის, როდესაც ქმნის entity-ის ინსტანსებს მონაცემთა ბაზის რიგებიდან. მის პარალელურად შეგიძლიათ გქონდეთ დამატებითი კონსტრუქტორებიც. ### ძირითადი JPA ანოტაციები | ანოტაცია | დანიშნულება | |------------|---------| | `@Entity` | აღნიშნავს კლასს როგორც JPA entity-ს (ასახულს მონაცემთა ბაზის ცხრილზე). | | `@Table(name = "...")` | განსაზღვრავს ცხრილის სახელს (არასავალდებულო, ნაგულისხმევად კლასის სახელია). | | `@Id` | აღნიშნავს primary key ველს. | | `@GeneratedValue` | აკონფიგურირებს primary key-ს ავტო-გენერაციის სტრატეგიას. | | `@Column` | აკონფიგურირებს სვეტის mapping-ს (სახელი, nullable, სიგრძე, unique და ა.შ.). | | `@Transient` | გამორიცხავს ველს მონაცემთა ბაზის mapping-იდან. | | `@Enumerated` | აკავშირებს enum ველს (`EnumType.STRING` ან `EnumType.ORDINAL`). | ### გენერაციის სტრატეგიები | სტრატეგია | ქცევა | |----------|----------| | `GenerationType.IDENTITY` | მონაცემთა ბაზის auto-increment სვეტი (გავრცელებულია MySQL-ში, PostgreSQL-ში). | | `GenerationType.SEQUENCE` | იყენებს მონაცემთა ბაზის sequence ობიექტს (უპირატესობა ენიჭება PostgreSQL-ისთვის მაღალი დატვირთვის სცენარებში). | | `GenerationType.AUTO` | აძლევს JPA პროვაიდერს საშუალებას, აირჩიოს სტრატეგია მონაცემთა ბაზის მიხედვით. | | `GenerationType.TABLE` | იყენებს ცალკეულ ცხრილს sequence-ების სიმულაციისთვის (იშვიათად გამოიყენება პრაქტიკაში). | --- ## 4.4 კავშირების ასახვა (Mapping Relationships) რეალური აპლიკაციები მოიცავს ერთმანეთთან დაკავშირებულ entity-ებს — პროდუქტი ეკუთვნის კატეგორიას, სტუდენტი რეგისტრირდება კურსებზე, მომხმარებელს აქვს პროფილი. JPA უზრუნველყოფს ანოტაციებს ამ კავშირების გამოსახატავად და Hibernate თარგმნის მათ შესაბამის foreign key-ებად და join ცხრილებად. ### @ManyToOne და @OneToMany ეს არის კავშირის ყველაზე გავრცელებული ტიპი. ბევრი პროდუქტი ეკუთვნის ერთ კატეგორიას და ერთ კატეგორიას აქვს ბევრი პროდუქტი: ```java @Entity public class Category { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @OneToMany(mappedBy = "category", cascade = CascadeType.ALL) private List products = new ArrayList<>(); // constructors, getters, setters } @Entity public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private Double price; @ManyToOne @JoinColumn(name = "category_id") private Category category; // constructors, getters, setters } ``` აქ რამდენიმე რამ ხდება, რაც ყურადღებას იმსახურებს. `@ManyToOne` `Product` მხარეს ამბობს: "ბევრი პროდუქტი შეიძლება ეკუთვნოდეს ერთ კატეგორიას." ეს არის კავშირის **მფლობელი მხარე (owning side)** — მხარე, რომელიც შეიცავს foreign key-ს მონაცემთა ბაზაში. `@JoinColumn(name = "category_id")` განსაზღვრავს foreign key სვეტის სახელს `products` ცხრილში. თუ გამოტოვებთ, JPA დააგენერირებს ნაგულისხმევ სახელს. `@OneToMany(mappedBy = "category")` `Category` მხარეს ამბობს: "ერთ კატეგორიას აქვს ბევრი პროდუქტი." `mappedBy` ატრიბუტი ეუბნება JPA-ს, რომ ეს მხარე არ ფლობს კავშირს — `Product` entity ფლობს მას თავისი `category` ველის მეშვეობით. ეს ხელს უშლის JPA-ს ზედმეტი join ცხრილის შექმნაში. `cascade = CascadeType.ALL` ნიშნავს, რომ `Category`-ზე შესრულებული ოპერაციები გადაეცემა (cascade) მის `Product` შვილებსაც. თუ თქვენ შეინახავთ კატეგორიას, მისი პროდუქტებიც შეინახება. თუ წაშლით კატეგორიას, მისი პროდუქტებიც წაიშლება. გამოიყენეთ cascade ფრთხილად — ის ძლიერია, მაგრამ შეიძლება გამოიწვიოს არასასურველი მასობრივი წაშლა, თუ დაუფიქრებლად გამოიყენებთ. ### @ManyToMany ზოგიერთი კავშირი არის many-to-many — მაგალითად, სტუდენტები რეგისტრირებულნი კურსებზე, სადაც თითოეულ სტუდენტს შეუძლია გაიაროს მრავალი კურსი და თითოეულ კურსს შეუძლია ჰყავდეს მრავალი სტუდენტი: ```java @Entity public class Student { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToMany @JoinTable( name = "student_courses", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id") ) private Set courses = new HashSet<>(); } @Entity public class Course { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @ManyToMany(mappedBy = "courses") private Set students = new HashSet<>(); } ``` `@JoinTable` აკონფიგურირებს join ცხრილს, რომელსაც მონაცემთა ბაზა იყენებს many-to-many კავშირის წარმოსადგენად. `joinColumns` ატრიბუტი განსაზღვრავს foreign key-ს, რომელიც მიუთითებს მფლობელ მხარეზე (`Student`), ხოლო `inverseJoinColumns` განსაზღვრავს foreign key-ს, რომელიც მიუთითებს ინვერსიულ მხარეზე (`Course`). JPA ქმნის და მართავს ამ join ცხრილს ავტომატურად. ### @OneToOne One-to-one კავშირი აკავშირებს ერთ entity-ს ზუსტად ერთ სხვა ინსტანსთან — მაგალითად, მომხმარებელი და მისი პროფილი: ```java @Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; @OneToOne(cascade = CascadeType.ALL) @JoinColumn(name = "profile_id") private UserProfile profile; } ``` ### Cascade-ის ტიპები (Cascade Types) | ტიპი | ეფექტი | |------|--------| | `CascadeType.PERSIST` | როდესაც მშობელი ინახება, შვილიც ინახება. | | `CascadeType.MERGE` | როდესაც მშობელი ახლდება, შვილიც ახლდება. | | `CascadeType.REMOVE` | როდესაც მშობელი იშლება, შვილიც იშლება. | | `CascadeType.ALL` | ყველა ზემოთ ჩამოთვლილი, პლუს `REFRESH` და `DETACH`. | ### Fetch-ის ტიპები (Fetch Types) | ტიპი | ქცევა | |------|----------| | `FetchType.LAZY` | დაკავშირებული entity-ები იტვირთება მხოლოდ მაშინ, როდესაც მათზე წვდომას მოითხოვთ (ნაგულისხმევი კოლექციებისთვის). | | `FetchType.EAGER` | დაკავშირებული entity-ები იტვირთება დაუყოვნებლივ მშობელთან ერთად (ნაგულისხმევი ერთმნიშვნელობიანი ასოციაციებისთვის). | ```java @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id") private Category category; ``` `FetchType.LAZY` თითქმის ყოველთვის სწორი არჩევანია. Eager ჩატვირთვა მოსახერხებლად გამოიყურება, მაგრამ სწრაფად იწვევს წარმადობის პრობლემებს — ერთი entity-ის ჩატვირთვამ შეიძლება გამოიწვიოს დამატებითი query-ების კასკადი, რომლებიც ჩატვირთავს ობიექტების მთელ გრაფებს, რომლებიც არასდროს დაგჭირვებიათ. დაიწყეთ lazy ჩატვირთვით და გადართეთ eager-ზე მხოლოდ მაშინ, თუ ამის კონკრეტული მიზეზი გაქვთ. --- ## 4.5 Repository-ების შექმნა Spring Data JPA-თი სწორედ აქ გამორიცხავს Spring Data JPA ზედმეტი კოდის უზარმაზარ რაოდენობას. ტრადიციულ JPA აპლიკაციაში მოგიწევდათ მონაცემებთან წვდომის კლასის დაწერა მეთოდებით, რომლებიც იყენებენ `EntityManager`-ს query-ების ასაგებად, მათ შესასრულებლად, ტრანზაქციების სამართავად და შედეგების დასაბრუნებლად. Spring Data JPA ანაცვლებს ამ ყველაფერს ერთი ინტერფეისის დეკლარაციით: ```java public interface ProductRepository extends JpaRepository { // That's it. You now have save(), findById(), findAll(), delete(), count(), and more. } ``` თქვენ არ წერთ იმპლემენტაციის კლასს. Spring Data JPA გაგენერირებთ მას გაშვების დროს (runtime). ინტერფეისი აფართოებს `JpaRepository`-ს, სადაც `Product` არის entity-ის ტიპი და `Long` არის მისი primary key-ს ტიპი. ### JpaRepository `CrudRepository`-ის წინააღმდეგ | ინტერფეისი | რას უზრუნველყოფს | |-----------|-----------------| | `CrudRepository` | ძირითადი CRUD: `save`, `findById`, `findAll`, `delete`, `count`. | | `JpaRepository` | ყველაფერი რაც `CrudRepository`-შია, პლუს JPA-ს სპეციფიკური მეთოდები: `flush`, `saveAndFlush`, ჯგუფური წაშლა (batch deletes) და `findAll(Pageable)` პაგინაციისა და სორტირებისთვის. | უმეტეს შემთხვევაში, გააფართოვეთ `JpaRepository` — ის უზრუნველყოფს ყველაფერს, რასაც `CrudRepository` და მეტს. ### Repository-ის გამოყენება Service-ში repository ინექცირდება service კლასში კონსტრუქტორის ინექციის (constructor injection) მეშვეობით, ისევე როგორც ნებისმიერი სხვა Spring-ის მიერ მართული კომპონენტი: ```java @Service public class ProductService { private final ProductRepository productRepository; public ProductService(ProductRepository productRepository) { this.productRepository = productRepository; } public List findAll() { return productRepository.findAll(); } public Product findById(Long id) { return productRepository.findById(id) .orElseThrow(() -> new RuntimeException("Product not found")); } public Product save(Product product) { return productRepository.save(product); } public void deleteById(Long id) { productRepository.deleteById(id); } } ``` შეამჩნიეთ, რომ `findById` აბრუნებს `Optional`-ს. `orElseThrow` გამოძახება ხსნის მას (unwraps) და ისვრის გამონაკლისს, თუ ამ ID-ით პროდუქტი არ არსებობს. ეს უფრო უსაფრთხო პატერნია, ვიდრე `null`-ის დაბრუნება — ის გაიძულებთ, რომ "ვერ მოიძებნა" შემთხვევა ექსპლიციტურად დაამუშაოთ. --- ## 4.6 წარმოებული (Derived) Query მეთოდები Spring Data JPA-ს ერთ-ერთი ყველაზე გამორჩეული თვისებაა მეთოდების სახელებიდან query-ების გენერირების შესაძლებლობა. თქვენ აცხადებთ მეთოდს თქვენს repository ინტერფეისში კონკრეტული დასახელების კონვენციის მიხედვით და Spring Data JPA აანალიზებს მეთოდის სახელს და ავტომატურად ქმნის შესაბამის query-ს: ```java public interface ProductRepository extends JpaRepository { List findByName(String name); List findByPriceGreaterThan(Double price); List findByNameContainingIgnoreCase(String keyword); List findByCategoryName(String categoryName); List findByPriceBetween(Double minPrice, Double maxPrice); Optional findByNameAndPrice(String name, Double price); List findByNameOrderByPriceAsc(String name); int countByCategory(Category category); boolean existsByName(String name); } ``` მეთოდის თითოეული სახელი არის წინადადება, რომელსაც Spring Data JPA კითხულობს: `findByPriceGreaterThan` ნიშნავს "იპოვე entity-ები, სადაც `price` ველი მეტია მოცემულ პარამეტრზე." `findByNameContainingIgnoreCase` ნიშნავს "იპოვე entity-ები, სადაც `name` ველი შეიცავს მოცემულ სტრიქონს, რეგისტრის (case) უგულებელყოფით." დასახელების კონვენცია ზუსტია — Spring Data JPA მოელის კონკრეტულ საკვანძო სიტყვებს კონკრეტულ პოზიციებზე. ### გავრცელებული საკვანძო სიტყვები Derived Query-ებისთვის | საკვანძო სიტყვა | მაგალითი | SQL-ის ეკვივალენტი | |---------|---------|----------------| | `findBy` | `findByName(String name)` | `WHERE name = ?` | | `Containing` | `findByNameContaining(String s)` | `WHERE name LIKE '%s%'` | | `GreaterThan` | `findByPriceGreaterThan(Double p)` | `WHERE price > ?` | | `LessThan` | `findByPriceLessThan(Double p)` | `WHERE price < ?` | | `Between` | `findByPriceBetween(Double a, Double b)` | `WHERE price BETWEEN ? AND ?` | | `OrderBy` | `findByNameOrderByPriceAsc(String n)` | `ORDER BY price ASC` | | `IgnoreCase` | `findByNameIgnoreCase(String n)` | `WHERE LOWER(name) = LOWER(?)` | წარმოებული query მეთოდები კარგად მუშაობს მარტივი query-ებისთვის. ნებისმიერი უფრო რთული ამოცანისთვის — query-ები, რომლებიც მოიცავს join-ებს, subquery-ებს, აგრეგაციებს, ან პირობებს, რომლებიც მეთოდის სახელს წაუკითხავს გახდიდა — მათ ნაცვლად უნდა გამოიყენოთ `@Query`. --- ## 4.7 მორგებული Query-ები @Query-ის გამოყენებით როდესაც წარმოებული query მეთოდები ზედმეტად მოუქნელი ხდება ან ვერ გამოხატავს იმ query-ს, რომელიც გჭირდებათ, `@Query` ანოტაცია გაძლევთ საშუალებას დაწეროთ JPQL (Java Persistence Query Language) უშუალოდ: ```java public interface ProductRepository extends JpaRepository { @Query("SELECT p FROM Product p WHERE p.price > :minPrice ORDER BY p.price ASC") List findExpensiveProducts(@Param("minPrice") Double minPrice); @Query("SELECT p FROM Product p WHERE LOWER(p.name) LIKE LOWER(CONCAT('%', :keyword, '%'))") List searchByName(@Param("keyword") String keyword); @Query("SELECT p FROM Product p JOIN p.category c WHERE c.name = :categoryName") List findByCategoryName(@Param("categoryName") String categoryName); @Query("SELECT COUNT(p) FROM Product p WHERE p.category.id = :categoryId") long countByCategory(@Param("categoryId") Long categoryId); } ``` JPQL ჰგავს SQL-ს, მაგრამ მუშაობს entity-ებსა და მათ ველებზე და არა ცხრილებსა და სვეტებზე. `SELECT p FROM Product p` ამოიღებს `Product` entity-ს და არა `products` ცხრილს. `p.category.name` მიჰყვება entity კლასში განსაზღვრულ კავშირს. `@Param` ანოტაცია აკავშირებს მეთოდის პარამეტრებს დასახელებულ პარამეტრებთან (`:minPrice`, `:keyword`) query-ში. თქვენ ასევე შეგიძლიათ გამოიყენოთ native SQL, როდესაც JPQL არ არის საკმარისი — მაგალითად, როდესაც გჭირდებათ მონაცემთა ბაზის სპეციფიკური ფუნქციები: ```java @Query(value = "SELECT * FROM products WHERE price > :minPrice", nativeQuery = true) List findExpensiveProductsNative(@Param("minPrice") Double minPrice); ``` Native query-ები გვერდს უვლიან entity აბსტრაქციას და მუშაობენ უშუალოდ ცხრილისა და სვეტის სახელებთან. გამოიყენეთ ისინი იშვიათად — ისინი აბამენ თქვენს კოდს მონაცემთა ბაზის კონკრეტულ დიალექტზე და კარგავენ პორტატიულობას, რომელსაც უზრუნველყოფს JPQL. --- ## 4.8 პაგინაცია და სორტირება როდესაც ცხრილი შეიცავს ათასობით რიგს, ყველა მათგანის ერთდროულად ჩატვირთვა არამიზნობრივი და ნელია. პაგინაცია გაძლევთ საშუალებას ჩატვირთოთ მონაცემები თითო გვერდად, ხოლო სორტირება აძლევს მომხმარებლებს საშუალებას აკონტროლონ თანმიმდევრობა. Spring Data JPA უზრუნველყოფს ჩაშენებულ მხარდაჭერას ორივესთვის `Pageable` ინტერფეისისა და `Page` დასაბრუნებელი ტიპის მეშვეობით. ### Repository-ში ნებისმიერ repository მეთოდს შეუძლია მიიღოს `Pageable` პარამეტრი და დააბრუნოს `Page`: ```java public interface ProductRepository extends JpaRepository { Page findByCategory(Category category, Pageable pageable); Page findByNameContainingIgnoreCase(String keyword, Pageable pageable); } ``` ### Service-ში service ქმნის `PageRequest`-ს (რომელიც აიმპლემენტირებს `Pageable`-ს), რომელიც მიუთითებს გვერდის ნომერს, გვერდის ზომასა და სორტირების რიგს: ```java @Service public class ProductService { private final ProductRepository productRepository; public ProductService(ProductRepository productRepository) { this.productRepository = productRepository; } public Page findAll(int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by("name").ascending()); return productRepository.findAll(pageable); } } ``` `PageRequest.of(page, size, Sort.by("name").ascending())` ქმნის მოთხოვნას მითითებული გვერდისთვის (რომელიც იწყება ნულიდან (zero-indexed)), თითო გვერდზე მოცემული რაოდენობის ნივთებით, დალაგებულს `name` ველის მიხედვით ზრდადობით. შეგიძლიათ ჯაჭვურად დააკავშიროთ სორტირების მრავალი კრიტერიუმი: `Sort.by("category").ascending().and(Sort.by("price").descending())`. ### Controller-ში controller იღებს გვერდსა და ზომას მოთხოვნის პარამეტრებიდან, იძახებს service-ს და გადასცემს პაგინაციის მონაცემებს შაბლონს: ```java @GetMapping("/products") public String listProducts(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, Model model) { Page productPage = productService.findAll(page, size); model.addAttribute("products", productPage.getContent()); model.addAttribute("currentPage", page); model.addAttribute("totalPages", productPage.getTotalPages()); model.addAttribute("totalItems", productPage.getTotalElements()); return "products/list"; } ``` ალტერნატიულად, Spring Data-ს შეუძლია პირდაპირ მოთხოვნიდან გადაწყვიტოს (resolve) `Pageable` პარამეტრი, რაც გამორიცხავს `PageRequest`-ის ხელით კონსტრუირებას. თქვენ ასევე შეგიძლიათ გამოიყენოთ `@SortDefault`, რათა მიუთითოთ სორტირების ნაგულისხმევი რიგი, როდესაც კლიენტი არ აწვდის მას: ```java @GetMapping("/products") public String listProducts(@PageableDefault(size = 9) @SortDefault(sort = "name", direction = Sort.Direction.ASC) Pageable pageable, Model model) { Page productPage = productService.findAll(pageable); model.addAttribute("products", productPage.getContent()); model.addAttribute("currentPage", pageable.getPageNumber()); model.addAttribute("totalPages", productPage.getTotalPages()); return "products/list"; } ``` ### პაგინირებული View-ების აგება FreeMarker-ით შაბლონი გადაუყვება პროდუქტებს და არენდერებს ნავიგაციის კონტროლერებს გვერდებს შორის გადასაადგილებლად: ```html <#import "/spring.ftl" as spring> <#import "layout.ftlh" as layout> <@layout.page title="Products">

Products

<#list products as product>
Name Price Category
${product.name} ${product.price} ${product.category.name}
``` `<#list 0..` კონსტრუქცია ქმნის დიაპაზონს 0-დან `totalPages`-მდე (არ ჩათვლის). მიმდინარე გვერდი გამოყოფილია CSS კლასით, ხოლო Previous/Next ბმულები პირობითად ჩანს მხოლოდ მაშინ, როდესაც არსებობს წინა ან შემდეგი გვერდი, სადაც შეიძლება გადასვლა. --- ## 4.9 ტრანზაქციების მართვა @Transactional-ით **ტრანზაქცია** აერთიანებს მონაცემთა ბაზის მრავალ ოპერაციას ერთ ატომურ ერთეულში — ან ყველა მათგანი წარმატებით სრულდება, ან ყველა უკან ბრუნდება (roll back). ტრანზაქციების გარეშე, მრავალსაფეხურიანი ოპერაციის შუაში წარმოქმნილმა მარცხმა შეიძლება დატოვოს თქვენი მონაცემები არათანმიმდევრულ მდგომარეობაში. Spring უზრუნველყოფს `@Transactional` ანოტაციას ტრანზაქციების დეკლარაციულად სამართავად. თქვენ აანოტირებთ service მეთოდს და Spring ავტომატურად შეფუთავს მის შესრულებას ტრანზაქციაში: ```java @Service public class OrderService { private final OrderRepository orderRepository; private final ProductRepository productRepository; public OrderService(OrderRepository orderRepository, ProductRepository productRepository) { this.orderRepository = orderRepository; this.productRepository = productRepository; } @Transactional public Order createOrder(Order order) { // If any of these operations fail, all changes are rolled back for (OrderItem item : order.getItems()) { Product product = productRepository.findById(item.getProductId()) .orElseThrow(() -> new RuntimeException("Product not found")); product.setStock(product.getStock() - item.getQuantity()); productRepository.save(product); } return orderRepository.save(order); } } ``` თუ `createOrder` მეთოდის შიგნით რომელიმე ოპერაცია ისვრის გამონაკლისს, მონაცემთა ბაზაში შეტანილი ყველა ცვლილება ამ მეთოდის ფარგლებში უკან დაბრუნდება — მარაგების რაოდენობა დაუბრუნდება საწყისს, შეკვეთა არ შეინახება. ეს ზუსტად ის ქცევაა, რაც გჭირდებათ: ან მთელი შეკვეთა იქმნება წარმატებით, ან არაფერი იცვლება. ძირითადი პუნქტები `@Transactional`-ის შესახებ: მოათავსეთ ის **service მეთოდებზე**, და არა repository მეთოდებზე. Repository მეთოდები უკვე ტრანზაქციულია ნაგულისხმევად (Spring Data JPA ხვევს თითოეულ repository-ს გამოძახებას საკუთარ ტრანზაქციაში). service მეთოდზე არსებული `@Transactional` ანოტაცია არის ის, რაც აკავშირებს მრავალი repository-ის გამოძახებას ერთ ტრანზაქციაში. ნაგულისხმევად, Spring უკან აბრუნებს (rolls back) ტრანზაქციას **unchecked გამონაკლისებზე** (`RuntimeException` და მისი ქვეკლასები), მაგრამ არა checked გამონაკლისებზე. თუ გჭირდებათ rollback checked გამონაკლისებზე, მიუთითეთ ეს ექსპლიციტურად: `@Transactional(rollbackFor = Exception.class)`. მხოლოდ წაკითხვადი (read-only) ოპერაციებისთვის გამოიყენეთ `@Transactional(readOnly = true)`. ეს ანიშნებს Hibernate-ს, რომ მონაცემები არ შეიცვლება, რაც აძლევს მას საშუალებას გამოტოვოს dirty-checking ოპტიმიზაციები და პოტენციურად გააუმჯობესოს წარმადობა. --- ## 4.10 მონაცემთა ბაზის კონფიგურაცია Spring Boot ამარტივებს სხვადასხვა მონაცემთა ბაზებს შორის გადართვას სხვადასხვა გარემოსთვის. დეველოპმენტის დროს, in-memory H2 მონაცემთა ბაზა ინარჩუნებს სიმარტივესა და სისწრაფეს. production-ში, თქვენ იყენებთ სრულფასოვან მონაცემთა ბაზას, როგორიცაა PostgreSQL ან MySQL. ### H2 კონფიგურაცია (Development) ```properties # H2 Database (in-memory, for development) spring.datasource.url=jdbc:h2:mem:devdb spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password= # Enable H2 Console spring.h2.console.enabled=true spring.h2.console.path=/h2-console # JPA / Hibernate spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=true ``` H2 Console არის ვებზე დაფუძნებული მონაცემთა ბაზის ბრაუზერი, რომელიც ხელმისაწვდომია `http://localhost:8080/h2-console`-ზე. ის საშუალებას გაძლევთ შეამოწმოთ თქვენი ცხრილები, გაუშვათ SQL query-ები და დაადასტუროთ, რომ თქვენი entity-ები სწორად აისახება. ეს შეუფასებელია დეველოპმენტის დროს — როდესაც რაღაც ისე არ მუშაობს, როგორც მოსალოდნელია, console გაძლევთ საშუალებას პირდაპირ ნახოთ რა არის მონაცემთა ბაზაში. `spring.jpa.show-sql=true` ლოგავს ყოველ SQL-ის განაცხადს, რომელსაც Hibernate ასრულებს კონსოლში. ეს სასარგებლოა იმის გასაგებად, თუ რა query-ებს აგენერირებს თქვენი კოდი, მაგრამ production-ში უნდა გამოირთოს, რათა თავიდან აიცილოთ ხმაური ლოგებში. ### PostgreSQL კონფიგურაცია (Production) PostgreSQL-ზე გადასართავად, დაამატეთ დრაივერის დამოკიდებულება და განაახლეთ თქვენი property-ები: ```xml org.postgresql postgresql runtime ``` ```properties spring.datasource.url=jdbc:postgresql://localhost:5432/mydb spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.username=myuser spring.datasource.password=mypassword spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.hibernate.ddl-auto=validate spring.jpa.show-sql=false ``` შეამჩნიეთ ცვლილება `ddl-auto`-ში — დეველოპმენტში არსებული `create-drop`-იდან `validate`-მდე production-ში. თქვენ არასოდეს გსურთ, რომ Hibernate ავტომატურად ცვლიდეს თქვენს production სქემას. ### Hibernate-ის ddl-auto ვარიანტები | მნიშვნელობა | ქცევა | |-------|----------| | `none` | სქემის მართვა საერთოდ არ ხდება. | | `validate` | ამოწმებს, რომ მონაცემთა ბაზის სქემა ემთხვევა entity-ებს. ისვრის გამონაკლისს, თუ ისინი არ ემთხვევა, მაგრამ არასოდეს ცვლის სქემას. | | `update` | აახლებს სქემას ისე, რომ დაემთხვეს entity-ებს — ამატებს ახალ სვეტებსა და ცხრილებს, მაგრამ არასოდეს შლის არსებულებს. | | `create` | შლის და ხელახლა ქმნის სქემას აპლიკაციის ყოველ ჩართვაზე. ყველა არსებული მონაცემი იკარგება. | | `create-drop` | იგივეა რაც `create`, მაგრამ ასევე შლის სქემას აპლიკაციის გამორთვისას. იდეალურია ავტომატიზირებული ტესტებისთვის. | დეველოპმენტისთვის `create-drop` მოსახერხებელია — ყოველ ჯერზე სუფთად იწყებთ. production-ისთვის გამოიყენეთ `validate` (ან `none`, თუ სქემას გარედან მართავთ მიგრაციის ინსტრუმენტით). არასოდეს გამოიყენოთ `update` production-ში — ის უსაფრთხოდ გამოიყურება, მაგრამ შეუძლია შექმნას მოულოდნელი სქემის ცვლილებები და არ შეუძლია სვეტების ან ცხრილების წაშლა. --- ## 4.11 მონაცემთა ბაზის ინიციალიზაცია data.sql-ითა და schema.sql-ით Spring Boot-ს შეუძლია ავტომატურად შეასრულოს SQL სკრიპტები ჩართვისას, რაც სასარგებლოა დეველოპმენტის მონაცემთა ბაზის ტესტური მონაცემებით შესავსებად. `schema.sql` ქმნის ცხრილებს და სრულდება Hibernate-ის ინიციალიზაციამდე (თუ კონფიგურირებულია ასე). `data.sql` სვამს საწყის მონაცემებს და ეშვება მას შემდეგ, რაც სქემა უკვე არსებობს. ```sql -- data.sql INSERT INTO categories (name) VALUES ('Electronics'); INSERT INTO categories (name) VALUES ('Books'); INSERT INTO categories (name) VALUES ('Clothing'); INSERT INTO products (name, price, category_id) VALUES ('Laptop', 999.99, 1); INSERT INTO products (name, price, category_id) VALUES ('Spring in Action', 49.99, 2); ``` იმის უზრუნველსაყოფად, რომ `data.sql` გაეშვება მას შემდეგ, რაც Hibernate შექმნის სქემას (რაც აუცილებელია `ddl-auto=create-drop`-ის გამოყენებისას), დაამატეთ ეს property: ```properties spring.jpa.defer-datasource-initialization=true ``` ამ პარამეტრის გარეშე, Spring Boot-მა შეიძლება სცადოს `data.sql`-ის გაშვება მანამ, სანამ ცხრილები იარსებებს, რასაც შეცდომები მოჰყვება. მოათავსეთ ეს ფაილები `src/main/resources/`-ში — Spring Boot პოულობს მათ ავტომატურად კონვენციის მიხედვით. ეს მიდგომა კარგად მუშაობს დეველოპმენტისა და ტესტირებისთვის. production-ისთვის მის ნაცვლად გამოიყენეთ მიგრაციის ინსტრუმენტი. --- ## 4.12 მონაცემების პროგრამული ინიციალიზაცია CommandLineRunner-ით თუმცა `data.sql` კარგად მუშაობს მარტივი შემთხვევებისთვის, მას აქვს თავისი შეზღუდვები. SQL სკრიპტებით რთულია რანდომიზებული ტესტური მონაცემების გენერირება, ისინი ვერ სარგებლობენ თქვენი entity მოდელით ან ვალიდაციის ლოგიკით და სწრაფად ხდებიან მოუხერხებელი, როდესაც თქვენი სქემა მოიცავს კავშირებს. უფრო რთული ინიციალიზაციის სცენარებისთვის, Spring Boot გვთავაზობს პროგრამულ ალტერნატივას: `CommandLineRunner` ინტერფეისს. `CommandLineRunner` არის Spring-ის მიერ მართული კომპონენტი, რომლის `run` მეთოდი ეშვება ერთხელ, აპლიკაციის კონტექსტის ჩართვისთანავე. თქვენ შეგიძლიათ დააინექციროთ repository-ები მასში და გამოიყენოთ ისინი მონაცემთა ბაზის Java კოდით შესავსებად: ```java @Component @RequiredArgsConstructor public class DataInitializer implements CommandLineRunner { private final CategoryRepository categoryRepository; private final ProductRepository productRepository; @Override public void run(String... args) { Category electronics = new Category(); electronics.setName("Electronics"); categoryRepository.save(electronics); Category books = new Category(); books.setName("Books"); categoryRepository.save(books); Product laptop = new Product(); laptop.setName("Laptop"); laptop.setPrice(999.99); laptop.setCategory(electronics); productRepository.save(laptop); Product novel = new Product(); novel.setName("Spring in Action"); novel.setPrice(49.99); novel.setCategory(books); productRepository.save(novel); } } ``` ეს მიდგომა გაძლევთ Java-ს სრულ ძალას — შეგიძლიათ გამოიყენოთ ციკლები, წაიკითხოთ გარე ფაილებიდან (JSON, CSV), დააგენერიროთ შემთხვევითი მონაცემები და დაიცვათ იგივე ვალიდაციის წესები, რომლებსაც თქვენი entity-ები განსაზღვრავენ. ის განსაკუთრებით სასარგებლოა მაშინ, როდესაც თქვენი მონაცემთა მოდელი მოიცავს ისეთ კავშირებს, რომელთა გამოხატვა უშუალოდ (raw) SQL-ით შრომატევადი იქნებოდა. ### «Detached Entity Passed to Persist» მახე მონაცემების პროგრამულად შევსებისას, არსებობს ერთი გავრცელებული და დამაბრკოლებელი შეცდომა, რომელშიც ბევრი დეველოპერი ებმება. განიხილეთ ასეთი სცენარი: გაქვთ `Book` entity `@ManyToMany` კავშირით `Genre`-სთან და გსურთ JSON ფაილიდან წიგნების ინიციალიზაცია, სადაც თითოეული წიგნი მიუთითებს თავის ჟანრებზე სახელის მიხედვით. აი entity-ების სტრუქტურა: ```java @Entity public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) @JoinTable( name = "book_genres", joinColumns = @JoinColumn(name = "book_id"), inverseJoinColumns = @JoinColumn(name = "genre_id") ) private Set genres = new HashSet<>(); // other fields, constructors, getters, setters } @Entity public class Genre { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToMany(mappedBy = "genres") private Set books = new HashSet<>(); } ``` ახლა დავუშვათ, წერთ `CommandLineRunner`-ს, რომელიც ჯერ ინახავს ჟანრებს, შემდეგ კითხულობს წიგნებს JSON-იდან და დინამიურად (on the fly) ქმნის `Genre` ობიექტებს თითოეულ წიგნზე მისაბმელად: ```java @Component @RequiredArgsConstructor public class FakeDataFiller implements CommandLineRunner { private final BookRepository bookRepository; private final GenreRepository genreRepository; @Override public void run(String... args) { // Step 1: Save genres to the database Genre fiction = new Genre(); fiction.setName("Fiction"); genreRepository.save(fiction); Genre sciFi = new Genre(); sciFi.setName("Science Fiction"); genreRepository.save(sciFi); // Step 2: Create a book and attach genres — the WRONG way Book book = new Book(); book.setTitle("Dune"); // Creating a NEW Genre object with the same name, but it is NOT // the managed entity we just saved — it is a completely separate // Java object that JPA knows nothing about. Genre detachedGenre = new Genre(); detachedGenre.setId(1L); // Even setting the ID does not help detachedGenre.setName("Science Fiction"); book.setGenres(Set.of(detachedGenre)); bookRepository.save(book); // BOOM — Exception! } } ``` ეს სრულდება შეცდომით: ``` org.hibernate.PersistentObjectException: Detached entity passed to persist: com.example.genre.Genre ``` **რა მოხდა?** JPA მართავს entity-ებს **persistence კონტექსტის** მეშვეობით — ეს არის ერთგვარი ქეში, რომელიც თვალყურს ადევნებს მიმდინარე სესიის ფარგლებში მონაცემთა ბაზიდან ჩატვირთულ ან შენახულ თითოეულ entity-ს. როდესაც თქვენ გამოიძახეთ `genreRepository.save(fiction)`, ეს კონკრეტული `fiction` ობიექტი გახდა **მართვადი (managed)** entity persistence კონტექსტში. მაგრამ ამის შემდეგ შექმნილი `detachedGenre` ობიექტი არ არის იგივე Java ობიექტი — ეს არის სრულიად ახალი ინსტანსი, რომელიც JPA-ს არასოდეს უნახავს. მიუხედავად იმისა, რომ მას აქვს იგივე სახელი და იგივე ID-ც კი, JPA არ ცნობს მას, როგორც მონაცემთა ბაზაში უკვე არსებულ entity-ს. ის არის **მოწყვეტილი (detached)** entity. როდესაც იძახებთ `bookRepository.save(book)`-ს, Hibernate ხედავს, რომ `book` არის ახალი entity (ჯერ არ აქვს ID), ამიტომ ის იძახებს `persist()`-ს. რადგან `@ManyToMany` კავშირი მოიცავს `CascadeType.PERSIST`-ს, Hibernate აგრძელებს (cascades) persist ოპერაციას დაკავშირებულ ჟანრებზეც. ის ცდილობს `persist()` გაუკეთოს `detachedGenre`-ს — მაგრამ ამ entity-ს უკვე აქვს ID, რაც ეუბნება Hibernate-ს, რომ ის ნამდვილად ახალი არ არის. Hibernate ვერ აგვარებს ამ წინააღმდეგობას: თქვენ სთხოვთ ახალი entity-ის ჩასმას, რომელიც უკვე ირწმუნება, რომ გააჩნია მონაცემთა ბაზის იდენტობა. ამიტომ ის ისვრის `PersistentObjectException`-ს. ### გამოსავალი: ყოველთვის გამოიყენეთ მართვადი (Managed) Entity-ები გამოსავალი მარტივია — არასოდეს მიაბათ არამართვადი (unmanaged) entity ინსტანსები კავშირებს. ამის ნაცვლად, მოძებნეთ არსებული entity-ები repository-დან, რათა მიიღოთ მართვადი რეფერენსები: ```java @Component @RequiredArgsConstructor public class FakeDataFiller implements CommandLineRunner { private final BookRepository bookRepository; private final GenreRepository genreRepository; @Override public void run(String... args) { // Step 1: Save genres Genre fiction = new Genre(); fiction.setName("Fiction"); genreRepository.save(fiction); Genre sciFi = new Genre(); sciFi.setName("Science Fiction"); genreRepository.save(sciFi); // Step 2: Create a book and attach genres — the RIGHT way Book book = new Book(); book.setTitle("Dune"); // Look up the genre from the repository — this returns a MANAGED entity Genre managedSciFi = genreRepository.findByName("Science Fiction"); book.setGenres(Set.of(managedSciFi)); bookRepository.save(book); // Works correctly } } ``` `genreRepository.findByName("Science Fiction")` გამოძახება აბრუნებს იმავე მართვად entity-ს, რომელსაც persistence კონტექსტი უკვე ადევნებს თვალყურს. როდესაც Hibernate გადასცემს persist ოპერაციას ამ ჟანრს, ის ცნობს მას, როგორც არსებულ მართვად entity-ს და უბრალოდ ქმნის ჩანაწერს join ცხრილში — ყოველგვარი დუბლიკატის ჩასმის მცდელობის გარეშე. ეს პატერნი ვრცელდება ნებისმიერ კავშირზე, სადაც რეფერენსირებული entity უკვე არსებობს მონაცემთა ბაზაში. მიუხედავად იმისა, მუშაობთ `@ManyToOne`, `@ManyToMany`, თუ `@OneToOne`-თან, წესი იგივეა: **თუ entity უკვე პერსისტირებულია, ამოიღეთ ის repository-დან, სანამ სხვა entity-ს მიაბამთ**. იგივე ID-ის ან ველების მნიშვნელობების მქონე ახალი Java ობიექტის შექმნა საკმარისი არ არის — JPA-სთვის მნიშვნელოვანია ობიექტის იდენტობა persistence კონტექსტში და არა მხოლოდ მონაცემთა ბაზის იდენტობა. --- ## 4.13 სქემის მიგრაცია Flyway-ითა და Liquibase-ით (მიმოხილვა) მონაცემთა ბაზის სქემის ცვლილებების ხელით მართვა სარისკოა — ადვილია ნაბიჯის დავიწყება, ცვლილებების არასწორი თანმიმდევრობით გამოყენება, ან იმის თვალის დაკარგვა, თუ რა იქნა გამოყენებული რომელ გარემოში. სქემის მიგრაციის ინსტრუმენტები ჭრიან ამას ვერსიით კონტროლირებული, გამეორებადი და ავტომატიზირებული მონაცემთა ბაზის მიგრაციების უზრუნველყოფით. ### Flyway Flyway იყენებს დანომრილ SQL სკრიპტებს, რომლებიც სრულდება თანმიმდევრობით. თითოეული სკრიპტი დასახელებულია ვერსიის პრეფიქსით — `V1__`, `V2__` და ა.შ. — და Flyway აკონტროლებს, რომელი სკრიპტები უკვე შესრულდა. ის უშვებს მხოლოდ ახლებს. ```xml org.flywaydb flyway-core ``` მოათავსეთ მიგრაციის სკრიპტები `src/main/resources/db/migration/`-ში: ```sql -- V1__Create_products_table.sql CREATE TABLE products ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, price DECIMAL(10, 2) NOT NULL, description VARCHAR(500) ); ``` ```sql -- V2__Add_category_column.sql ALTER TABLE products ADD COLUMN category_id BIGINT REFERENCES categories(id); ``` Flyway ქმნის მეტამონაცემების ცხრილს (`flyway_schema_history`) თქვენს მონაცემთა ბაზაში, რომელიც ინახავს ჩანაწერს იმის შესახებ, თუ რომელი მიგრაციები იქნა გამოყენებული და როდის. აპლიკაციის ყოველ ჩართვაზე ის ადარებს დისკზე არსებულ მიგრაციის ფაილებს მეტამონაცემების ცხრილს და იყენებს მხოლოდ მათ, რომლებიც ახალია. ეს ხდის deployment-ს უსაფრთხოს და გამეორებადს — შეგიძლიათ დარწმუნებული იყოთ, რომ ყველა გარემოს აქვს ერთი და იგივე სქემა. ### Liquibase Liquibase იყენებს განსხვავებულ მიდგომას — მას აქვს XML, YAML, JSON ან SQL changeset-ების მხარდაჭერა და უზრუნველყოფს მეტ მოქნილობას მიგრაციების განსაზღვრაში. ის უფრო ძლიერია ვიდრე Flyway რთული სცენარებისთვის, მაგრამ ასევე უფრო რთულია დასაყენებლად. ```xml org.liquibase liquibase-core ``` ორივე ინსტრუმენტი production-grade-ისაა და ფართოდ გამოიყენება. Flyway უფრო მარტივია და კარგი ნაგულისხმევი არჩევანია. Liquibase-ის განხილვა ღირს, თუ გჭირდებათ rollback-ის მხარდაჭერა, მრავალი მონაცემთა ბაზის მიზანში ამოღება (multi-database targeting) ან უფრო გრანულარული კონტროლი მიგრაციის ლოგიკაზე. --- ## 4.14 პრაქტიკული პატერნები მონაცემთა ბაზაზე დაფუძნებული აპლიკაციებისთვის როდესაც თქვენი აპლიკაცია იზრდება საბაზისო CRUD-ის მიღმა, რამდენიმე პატერნი განმეორებით იჩენს თავს. ეს სექცია ფარავს ყველაზე მნიშვნელოვან მათგანს: ფორმის ობიექტების entity-ებისგან განცალკევებას, "არ მოიძებნა" (not found) სცენარების სუფთად დამუშავებას და ბინარული მონაცემების შენახვას. ### ფორმის ობიექტების განცალკევება Entity-ებისგან ამ თავში უფრო ადრე, თქვენ ნახეთ entity-ები, რომლებიც ატარებენ როგორც JPA ანოტაციებს, ასევე Bean Validation ანოტაციებს — ასრულებენ რა ორმაგ მოვალეობას, როგორც მონაცემთა ბაზის mapping-ს, ასევე ფორმის დამხმარე ობიექტებს. ეს მუშაობს მარტივ შემთხვევებში, მაგრამ ქმნის პრობლემებს თქვენი აპლიკაციის ზრდასთან ერთად. Entity-ს შეიძლება ჰქონდეს ველები, რომლებიც არ უნდა იყოს რედაქტირებადი ფორმის მეშვეობით (როგორიცაა `createdAt` ან შიდა კავშირები), ან ფორმას შეიძლება სჭირდებოდეს ველები, რომლებიც არ არსებობს entity-ში (როგორიცაა პაროლის დადასტურების ველი ან ფაილის ატვირთვა). უფრო სუფთა მიდგომაა გამოყოფილი ფორმის კლასის (რომელსაც ზოგჯერ უწოდებენ DTO-ს — Data Transfer Object) გამოყენება ფორმის დასაკავშირებლად, და შემდეგ მისი ასახვა (map) entity-ზე controller-ში ან service-ში: ```java public class BookForm { @NotBlank private String title; @NotBlank private String author; @NotNull private Integer publishedYear; @NotBlank private String isbn; @DecimalMin("0.0") @DecimalMax("5.0") private Double rating; @NotEmpty private List genreIds; private MultipartFile coverImage; // getters and setters } ``` ფორმის კლასი ატარებს მხოლოდ იმ ველებს, რომლებსაც მომხმარებელი აგზავნის, ფორმაზე მორგებული ვალიდაციის წესებით. `genreIds` ველი ინახავს ჟანრის ID-ებს როგორც `Long` მნიშვნელობებს — და არა entity რეფერენსებს. `coverImage` ველი არის `MultipartFile` ფაილის ატვირთვებისთვის — რასაც ადგილი არ აქვს JPA entity-ზე. controller-ში თქვენ ამოწმებთ (validate) ფორმას, შემდეგ ასახავთ (map) მას entity-ზე: ```java @PostMapping("/book/new") public String addBook(@Valid @ModelAttribute BookForm bookForm, BindingResult bindingResult, Model model) throws IOException { if (bindingResult.hasErrors()) { model.addAttribute("genres", genreService.getAllGenres()); return "add-book"; } Book book = new Book(); book.setTitle(bookForm.getTitle()); book.setAuthor(bookForm.getAuthor()); book.setPublishedYear(bookForm.getPublishedYear()); book.setIsbn(bookForm.getIsbn()); book.setRating(bookForm.getRating()); book.setGenres(genreService.getGenresByIds(bookForm.getGenreIds())); book.setCreatedAt(LocalDateTime.now()); bookService.saveBook(book); return "redirect:/book/" + book.getId(); } ``` ეს განცალკევება ინარჩუნებს თქვენს entity-ს სუფთად — ის რჩება სუფთა მონაცემთა ბაზის mapping-ად ფორმასთან დაკავშირებული საკითხების გარეშე. ის ასევე ხდის ვალიდაციას უფრო მოქნილს: ფორმას შეუძლია მოითხოვოს ველები, რომლებიც არასავალდებულოა entity-ზე, ან გამოტოვოს ველები, რომლებსაც სერვერი ავტომატურად აყენებს. ### მორგებული გამონაკლისები (Custom Exceptions) "ვერ მოიძებნა" სცენარებისთვის როდესაც მომხმარებელი ითხოვს entity-ს, რომელიც არ არსებობს — ვთქვათ, `/book/9999` — თქვენ უნდა დააბრუნოთ აზრიანი შეცდომა stack trace-ის ნაცვლად. სტანდარტული პატერნია შექმნათ მორგებული გამონაკლისი (custom exception) და ისროლოთ ის service-ის ფენიდან: ```java public class BookNotFoundException extends RuntimeException { public BookNotFoundException(Long id) { super("Book with ID " + id + " was not found."); } } ``` ```java public Book getBookById(Long id) { return bookRepository.findById(id) .orElseThrow(() -> new BookNotFoundException(id)); } ``` ეს ბუნებრივად წყვილდება `@ControllerAdvice` გამონაკლისების მართვის მექანიზმთან, რომელიც განვიხილეთ მე-3 თავში — გლობალური `@ExceptionHandler` იჭერს `BookNotFoundException`-ს, აყენებს HTTP სტატუსს 404-ზე და არენდერებს მომხმარებლისთვის გასაგებ შეცდომის გვერდს. მსგავსი დომენისთვის სპეციფიკური გამონაკლისების შექმნა ხდის თქვენს შეცდომების მართვას ექსპლიციტურს და ტიპებისადმი უსაფრთხოს (type-safe): თქვენ შეგიძლიათ ზუსტად დაინახოთ, რომელი ჩავარდნის სცენარებს ამუშავებს თქვენი აპლიკაცია და როგორ არის თითოეული მათგანი წარმოდგენილი მომხმარებლისთვის. ### ბინარული მონაცემების შენახვა Entity-ებში ზოგჯერ გჭირდებათ ფაილების — სურათების, დოკუმენტების, დანართების — შენახვა თქვენი entity მონაცემების გვერდით. ყველაზე მარტივი მიდგომაა ფაილის შენახვა, როგორც ბაიტების მასივის (byte array) უშუალოდ მონაცემთა ბაზაში: ```java @Entity public class Book { // ... other fields ... @Column(name = "cover_image") private byte[] coverImage; @Column(name = "cover_image_type") private String coverImageType; } ``` შენახვისას, წაიკითხეთ ატვირთული ფაილის ბაიტები და კონტენტის ტიპი `MultipartFile`-დან: ```java if (!bookForm.getCoverImage().isEmpty()) { book.setCoverImage(bookForm.getCoverImage().getBytes()); book.setCoverImageType(bookForm.getCoverImage().getContentType()); } ``` სურათის უკან დასაბრუნებლად (serve), შექმენით სპეციალური controller, რომელიც აბრუნებს უშუალო (raw) ბაიტებს სწორი `Content-Type` ჰედერით: ```java @Controller public class ImageController { private final BookService bookService; public ImageController(BookService bookService) { this.bookService = bookService; } @GetMapping("/image/cover/{bookId}") public ResponseEntity getCoverImage(@PathVariable Long bookId) { Book book = bookService.getBookById(bookId); if (book.getCoverImage() == null) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok() .contentType(MediaType.parseMediaType(book.getCoverImageType())) .body(book.getCoverImage()); } } ``` თქვენს შაბლონში, მიუთითეთ სურათი ამ ენდფოინთის მეშვეობით: ``. ეს მიდგომა კარგად მუშაობს პატარა ფაილებისა და მარტივი აპლიკაციებისთვის. უფრო დიდი ფაილებისთვის ან მაღალი ტრაფიკის მქონე აპლიკაციებისთვის, ფაილების ფაილურ სისტემაზე ან ღრუბლოვან ობიექტების საცავში (cloud object storage, როგორიცაა Amazon S3) შენახვა და მონაცემთა ბაზაში მხოლოდ ფაილის გზის (path) დატოვება უფრო ეფექტურია — დიდი BLOB-ები ზრდის მონაცემთა ბაზის სარეზერვო ასლის (backup) ზომას და ანელებს query-ებს, რომლებიც ტვირთავენ entity-ს. --- ## 4.15 ყველაფრის ერთად თავმოყრა: სრული მაგალითი იმის სანახავად, თუ როგორ უკავშირდება entity-ები, repository-ები, service-ები და controller-ები რეალურ აპლიკაციაში ერთმანეთს, აქ არის სრული მაგალითი — "Gossip" პოსტების აპლიკაცია: ### Entity ```java @Entity @Table(name = "gossips") public class Gossip { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank @Size(max = 280) private String content; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "author_id", nullable = false) private User author; @Column(name = "created_at") private LocalDateTime createdAt = LocalDateTime.now(); // constructors, getters, setters } ``` შეამჩნიეთ, რომ entity აერთიანებს JPA ანოტაციებს (`@Entity`, `@ManyToOne`, `@JoinColumn`) Bean Validation ანოტაციებთან (`@NotBlank`, `@Size`) მე-3 თავიდან. ეს გავრცელებული პატერნია — ერთი და იგივე კლასი ემსახურება როგორც მონაცემთა ბაზის mapping-ს, ასევე ფორმის დამხმარე ობიექტს ვალიდაციის წესებით. ### Repository ```java public interface GossipRepository extends JpaRepository { List findByAuthorOrderByCreatedAtDesc(User author); Page findAllByOrderByCreatedAtDesc(Pageable pageable); @Query("SELECT g FROM Gossip g WHERE g.content LIKE %:keyword%") List search(@Param("keyword") String keyword); } ``` ეს repository იყენებს წარმოებულ query მეთოდებს წვდომის გავრცელებული პატერნებისთვის და `@Query` მეთოდს ძიების ფუნქციონალისთვის, რომლის გამოხატვაც მხოლოდ მეთოდის დასახელებით მოუხერხებელი იქნებოდა. ### Service ```java @Service public class GossipService { private final GossipRepository gossipRepository; public GossipService(GossipRepository gossipRepository) { this.gossipRepository = gossipRepository; } public Page getLatestGossips(int page, int size) { return gossipRepository.findAllByOrderByCreatedAtDesc( PageRequest.of(page, size)); } } ``` service შეგნებულად არის თხელი (thin) — ის დელეგირებს repository-სთან და უზრუნველყოფს სუფთა API-ს controller-ისთვის გამოსაყენებლად. აპლიკაციის ზრდასთან ერთად, service ფენა იქნება ის ადგილი, სადაც ბიზნეს ლოგიკა (მარტივი CRUD-ის მიღმა) იცხოვრებს. --- ## შეჯამება ეს თავი ფარავდა ინსტრუმენტებსა და ტექნიკებს Spring Boot აპლიკაციის რელაციურ მონაცემთა ბაზასთან დასაკავშირებლად. **ORM და JPA** ავსებს ნაპრალს Java ობიექტებსა და მონაცემთა ბაზის ცხრილებს შორის. Hibernate არის JPA იმპლემენტაცია, რომელსაც Spring Boot იყენებს ნაგულისხმევად, ხოლო Spring Data JPA უზრუნველყოფს უფრო მაღალი დონის აბსტრაქციას, რომელიც გამორიცხავს მონაცემებთან წვდომის ზედმეტი კოდის დიდ ნაწილს. **Entity-ები** არიან Java კლასები, ანოტირებული `@Entity`, `@Id`, `@GeneratedValue` და `@Column`-ით, რომლებიც აისახება მონაცემთა ბაზის ცხრილებზე. თითოეული entity-ის ინსტანსი შეესაბამება რიგს. **კავშირები** entity-ებს შორის გამოიხატება `@ManyToOne` / `@OneToMany`, `@ManyToMany` და `@OneToOne`-ით. კავშირის მფლობელი მხარე ინახავს `@JoinColumn`-ს, ხოლო `mappedBy` ინვერსიულ მხარეზე ხელს უშლის დუბლირებული foreign key-ების შექმნას. **Repository-ები** აფართოებენ `JpaRepository`-ს და უზრუნველყოფენ CRUD ოპერაციებს, პაგინაციას და სორტირებას ყოველგვარი იმპლემენტაციის კოდის გარეშე. **წარმოებული (Derived) query მეთოდები** აგენერირებენ query-ებს მეთოდის სახელებიდან, ხოლო `@Query` გაძლევთ საშუალებას დაწეროთ მორგებული JPQL ან native SQL. **პაგინაცია და სორტირება** იმართება `Pageable`, `PageRequest` და `Page` მეშვეობით, რაც საშუალებას გაძლევთ ჩატვირთოთ მონაცემები თითო გვერდად და წარმოადგინოთ ნავიგაციის კონტროლერები თქვენს შაბლონებში. **`@Transactional`** აერთიანებს მონაცემთა ბაზის მრავალ ოპერაციას ატომურ ერთეულში — ან ყველა სრულდება წარმატებით, ან ყველა უკან ბრუნდება. მოათავსეთ ის service მეთოდებზე მონაცემთა თანმიმდევრულობის უზრუნველსაყოფად. **მონაცემთა ბაზის კონფიგურაცია** იყენებს `application.properties`-ს H2-ს (დეველოპმენტისთვის) და PostgreSQL-ს ან MySQL-ს (production-ისთვის) შორის გადასართავად. `ddl-auto` property აკონტროლებს, თუ როგორ მართავს Hibernate სქემას. **ფორმის ობიექტები (DTO-ები)** უნდა განცალკევდეს entity-ებისგან თქვენი აპლიკაციის ზრდასთან ერთად. სპეციალური ფორმის კლასი ამუშავებს ვალიდაციას და მომხმარებლის მიერ შეყვანილ მონაცემებს, ხოლო entity რჩება სუფთა მონაცემთა ბაზის mapping-ად. მოახდინეთ მათი ასახვა (map) ერთმანეთზე controller-ში ან service-ში. **მორგებული გამონაკლისები (Custom exceptions)**, როგორიცაა `BookNotFoundException`, ხდის "entity არ მოიძებნა" შეცდომებს ექსპლიციტურს. ისროლეთ ისინი service-ის ფენიდან `orElseThrow()`-ის გამოყენებით და დაამუშავეთ ისინი მე-3 თავში განხილული `@ControllerAdvice` მექანიზმით. **ბინარული მონაცემები**, როგორიცაა სურათები, შეიძლება შეინახოს როგორც `byte[]` ველები entity-ებზე და დაბრუნდეს სპეციალური controller-ის მეშვეობით სწორი `Content-Type` ჰედერით. დიდი ფაილებისთვის ან მაღალი ტრაფიკის მქონე აპლიკაციებისთვის, უპირატესობა მიანიჭეთ გარე საცავს მონაცემთა ბაზაში მხოლოდ ფაილის გზის (path) შენახვით. **მონაცემთა ბაზის ინიციალიზაცია** `data.sql`-ით ავსებს დეველოპმენტის მონაცემთა ბაზებს ტესტური მონაცემებით. უფრო რთული სცენარებისთვის, **`CommandLineRunner`** უზრუნველყოფს პროგრამულ ინიციალიზაციას თქვენს repository-ებსა და entity მოდელზე სრული წვდომით. ინიციალიზაციის დროს კავშირებთან მუშაობისას, ყოველთვის ამოიღეთ არსებული entity-ები repository-დან ახალი Java ობიექტების შექმნის ნაცვლად — წინააღმდეგ შემთხვევაში, JPA ისვრის "detached entity passed to persist" შეცდომას. **სქემის მიგრაციის** ინსტრუმენტები, როგორიცაა Flyway და Liquibase, უზრუნველყოფენ ვერსიით კონტროლირებულ, გამეორებად მიგრაციებს production გარემოსთვის. --- ## რესურსები - [Spring Data JPA Documentation](https://docs.spring.io/spring-data/jpa/docs/current/reference/html/) - [Jakarta Persistence (JPA) Specification](https://jakarta.ee/specifications/persistence/) - [Hibernate ORM Documentation](https://hibernate.org/orm/documentation/) - [Baeldung: Spring Data JPA](https://www.baeldung.com/the-persistence-layer-with-spring-data-jpa) - [H2 Database](https://www.h2database.com/) - [Flyway Documentation](https://documentation.red-gate.com/fd) - [Liquibase Documentation](https://docs.liquibase.com/) --- ## პრაქტიკული დავალება: ააშენეთ მონაცემთა ბაზაზე დაფუძნებული ვებ აპლიკაცია გააფართოვეთ თქვენი ინტერაქტიული ვებ აპლიკაცია მე-3 თავიდან პერსისტენტული მონაცემთა საცავით. **მოთხოვნები:** 1. **განსაზღვრეთ JPA entity-ები** სათანადო ანოტაციებით (`@Entity`, `@Id`, `@GeneratedValue`, `@Column`). გამოიყენეთ **განცალკევებული ფორმის/DTO კლასები** ფორმის დასაკავშირებლად და ვალიდაციისთვის, რათა თქვენი entity-ები ფოკუსირებული დარჩეს მონაცემთა ბაზის mapping-ზე. 2. **დააკავშირეთ მინიმუმ ერთი ურთიერთობა** entity-ებს შორის (მაგ., `@ManyToOne` / `@OneToMany` ბლოგის პოსტებსა და კატეგორიას ან ავტორის entity-ს შორის). 3. **შექმენით repository ინტერფეისები**, რომლებიც აფართოებენ `JpaRepository`-ს და დაამატეთ მინიმუმ ორი წარმოებული query მეთოდი ან `@Query` მეთოდი — მაგალითად, ძიების მეთოდი და მეთოდი, რომელიც ფილტრავს კატეგორიის მიხედვით. 4. **დააიმპლემენტირეთ პაგინაცია** გვერდების სიისთვის — აჩვენეთ კონფიგურირებადი რაოდენობის ნივთები თითო გვერდზე Previous/Next ნავიგაციის კონტროლერებით, რომლებიც დარენდერებულია FreeMarker-ში. 5. **გამოიყენეთ H2 მონაცემთა ბაზა** დეველოპმენტისთვის ჩართული H2 console-ით და წინასწარ შეავსეთ მონაცემები `data.sql`-ის ან `CommandLineRunner`-ის გამოყენებით. 6. **დაამატეთ `@Transactional`** სადაც ეს მიზანშეწონილია — მაგალითად, service მეთოდებზე, რომლებიც ცვლიან მრავალ entity-ს ერთ ოპერაციაში. 7. **დაამუშავეთ შეცდომები ელეგანტურად (gracefully)** — შექმენით მორგებული გამონაკლისი "entity არ მოიძებნა" სცენარებისთვის და `@ControllerAdvice`, რომელიც არენდერებს მომხმარებლისთვის გასაგებ შეცდომის გვერდებს.