diff --git a/docs/content/docs/7-advanced-querying.md b/docs/content/docs/7-advanced-querying.md index f7fa911..6bc5634 100644 --- a/docs/content/docs/7-advanced-querying.md +++ b/docs/content/docs/7-advanced-querying.md @@ -5,118 +5,341 @@ weight: 7 # Advanced Querying -The auto-generated repository methods (`findBy...`) are excellent for standard CRUD operations. -However, enterprise applications frequently require eager loading (preloads), pagination, and -complex joins. +Auto-generated repository methods (`findBy...`) are fast, convenient, and handle standard CRUD operations effortlessly. +However, enterprise applications frequently require dynamic query shaping: eager loading (preloads), pagination, +custom sorting, ad-hoc filters, and complex joins. -Rays ORM handles this complexity using the `OrmQueryFunc` escape hatch, keeping your repository -interfaces clean and putting data hydration logic squarely in the hands of the business service layer. +Rays ORM handles this complexity using the **`OrmQueryFunc`** escape hatch. By accepting variadic `...OrmQueryFunc` +parameters, repository methods keep their interfaces clean and place data hydration logic squarely in the hands +of the business service layer. -## 1. Relationships & Preloading +The `github.com/BeamFoundry/rays-orm/pkg/util` package provides a rich set of built-in `OrmQueryFunc` helpers ready +for production use: -Instead of embedding eager-loading logic into the method name string, Rays ORM provides helper -functions (`WithPreload` and `WithJoins`) that return an `OrmQueryFunc`. +```go +import ( + . "github.com/BeamFoundry/rays-orm/pkg/core" + . "github.com/BeamFoundry/rays-orm/pkg/util" +) +``` + +--- + +## 1. Quick Reference: Built-in `OrmQueryFunc` Helpers -You can pass these helpers as variadic arguments to any auto-generated repository method to dynamically -shape the returned graph. +All helpers return an `OrmQueryFunc` (an alias for `func(*gorm.DB) *gorm.DB`) that can be passed to any repository method or to `repo.Query()`: + +| Category | Function | Signature | Description | +|---|---|---|---| +| **Pagination** | `Paginate` | `Paginate(page, pageSize int) OrmQueryFunc` | Sets 1-indexed pagination (`offset = (page - 1) * pageSize`, `limit = pageSize`). Safe defaults for non-positive inputs. | +| **Limits** | `WithLimit` / `Limit` | `WithLimit(limit int) OrmQueryFunc` | Restricts the maximum number of records returned. | +| **Offset** | `WithOffset` | `WithOffset(offset int) OrmQueryFunc` | Skips the specified number of records before retrieving. | +| **Relations** | `WithPreload` | `WithPreload(query string, args ...any) OrmQueryFunc` | Eagerly loads relationships or nested associations with optional conditions. | +| **Joins** | `WithJoins` | `WithJoins(query string, args ...any) OrmQueryFunc` | Appends a SQL JOIN condition for eager loading or query filtering. | +| **Sorting** | `OrderByAsc` | `OrderByAsc(column string) OrmQueryFunc` | Sorts results by `column ASC`. | +| **Sorting** | `OrderByDesc` | `OrderByDesc(column string) OrmQueryFunc` | Sorts results by `column DESC`. | +| **Sorting** | `WithOrder` | `WithOrder(value any) OrmQueryFunc` | Applies arbitrary sorting expressions (e.g. `"priority DESC, created_at ASC"`). | +| **Projections** | `WithSelect` | `WithSelect(query any, args ...any) OrmQueryFunc` | Selects specific columns to hydrate from the database. | +| **Projections** | `WithOmit` | `WithOmit(columns ...string) OrmQueryFunc` | Excludes specific columns from model hydration. | +| **Projections** | `WithDistinct` | `WithDistinct(args ...any) OrmQueryFunc` | Enforces `DISTINCT` row selection. | +| **Filtering** | `WithWhere` | `WithWhere(query any, args ...any) OrmQueryFunc` | Adds a dynamic parameterized `WHERE` condition to the query. | +| **Filtering** | `WithOr` | `WithOr(query any, args ...any) OrmQueryFunc` | Adds a dynamic parameterized `OR` condition to the query. | +| **Grouping** | `WithGroup` | `WithGroup(name string) OrmQueryFunc` | Groups records by `GROUP BY name`. | +| **Grouping** | `WithHaving` | `WithHaving(query any, args ...any) OrmQueryFunc` | Adds a `HAVING` aggregation filter clause. | +| **Soft Deletes** | `WithUnscoped` | `WithUnscoped() OrmQueryFunc` | Bypasses soft-delete filtering to include soft-deleted records. | +| **Tables** | `WithTable` | `WithTable(name string, args ...any) OrmQueryFunc` | Overrides the target database table for partition or view queries. | +| **Composition** | `Combine` | `Combine(funcs ...OrmQueryFunc) OrmQueryFunc` | Chains multiple query functions into a single reusable scope. | + +--- + +## 2. Eager Loading & Relations + +Instead of baking eager-loading logic into complex method names, use `WithPreload` and `WithJoins` to dynamically specify association loading. + +### `WithPreload(query string, args ...any)` + +Loads associations in a separate query, avoiding Cartesian products on 1-to-many relationships: ```go package services import ( . "github.com/BeamFoundry/rays-orm/pkg/core" - . "github.com/BeamFoundry/rays/pkg/core" - . "github.com/BeamFoundry/rays/pkg/lang" + . "github.com/BeamFoundry/rays-orm/pkg/util" ) -type DashboardService struct { - Service - userRepo *UserRepository `@:"Inject"` -} - func (s *DashboardService) GetUserData(userId uint) Error { - - // 1. Fetching just the user (Lightweight) - user, _ := s.userRepo.FindById(userId).Get() - - // 2. Fetching the user WITH their Profile relation (Eager Loading) + // 1. Simple relation preload userWithProfile, _ := s.userRepo.FindById(userId, WithPreload("Profile")).Get() - // 3. Complex Nested Preloading + // 2. Nested association preload + userWithComments, _ := s.userRepo.FindById(userId, WithPreload("Posts.Comments")).Get() + + // 3. Conditional association preload detailedUser, _ := s.userRepo.FindById(userId, WithPreload("Profile"), - WithPreload("Posts", "status = ?", "published"), // Conditional preload - WithPreload("Posts.Comments"), // Nested preload + WithPreload("Posts", "status = ? AND created_at > ?", "published", lastMonth), ).Get() return nil } ``` -## 2. Pagination & Limits +### `WithJoins(query string, args ...any)` -Because `OrmQueryFunc` is simply an alias for `func(*gorm.DB) *gorm.DB`, you can easily write -your own helpers for features like pagination. +Appends a SQL JOIN statement to the query. Ideal for filtering records by relationship fields or eager-loading 1-to-1 / belongs-to associations: ```go -package utils +// Fetch users who have an active subscription via SQL JOIN +activeUsers := s.userRepo.FindAllByStatus("active", + WithJoins("JOIN subscriptions ON subscriptions.user_id = users.id AND subscriptions.status = ?", "ACTIVE"), +) +``` -import ( - "gorm.io/gorm" - . "github.com/BeamFoundry/rays-orm/pkg/core" +--- + +## 3. Pagination & Windowing + +### `Paginate(page, pageSize int)` + +`Paginate` converts a 1-indexed page number and page size into SQL `OFFSET` and `LIMIT` clauses: +$$\text{offset} = (\text{page} - 1) \times \text{pageSize}$$ + +Safety defaults & overflow protection: +* If `page <= 0`, it automatically defaults to `page = 1`. +* If `pageSize <= 0`, it defaults to `DefaultPageSize` (10). +* If `(page - 1) * pageSize` cannot be represented (overflows `math.MaxInt`), it safely attaches a query error (`PaginationOverflowError` with `"page"` and `"pageSize"` attributes) to the query rather than applying an offset that wraps around to a non-positive value. + +```go +func (s *UserService) ListUsers(page, pageSize int) []User { + return s.userRepo.FindAllByStatus("ACTIVE", + Paginate(page, pageSize), + OrderByDesc("created_at"), + ) +} +``` + +### `WithLimit(limit int)` and `Limit(limit int)` + +Restricts the number of rows returned without altering the offset: + +```go +// Fetch the top 5 newest registered users +latestUsers := s.userRepo.FindAllByRole("MEMBER", + OrderByDesc("created_at"), + Limit(5), // or WithLimit(5) +) +``` + +### `WithOffset(offset int)` + +Skips a fixed number of rows: + +```go +// Skip the first 10 rows and return the next records +users := s.userRepo.FindAllByRole("MEMBER", + OrderByAsc("id"), + WithOffset(10), + Limit(20), ) +``` + +--- + +## 4. Sorting & Ordering + +### `OrderByAsc(column string)` & `OrderByDesc(column string)` + +Provides clean, explicit directional ordering: + +```go +// Sort users by age ascending +youngestUsers := s.userRepo.FindAllByStatus("ACTIVE", OrderByAsc("age")) + +// Sort users by registration date descending +newestUsers := s.userRepo.FindAllByStatus("ACTIVE", OrderByDesc("created_at")) +``` + +### `WithOrder(value any)` + +Allows arbitrary SQL order clauses, including multi-column sorting: + +```go +// Multi-column ordering with custom directions +users := s.userRepo.FindAllByRole("ENGINEER", + WithOrder("department ASC, salary DESC, last_name ASC"), +) +``` + +--- + +## 5. Projections & Field Selection -// A custom pagination helper -func Paginate(page int, pageSize int) OrmQueryFunc { - return func(db *gorm.DB) *gorm.DB { - offset := (page - 1) * pageSize - return db.Offset(offset).Limit(pageSize) +### `WithSelect(query any, args ...any)` + +Restricts the SQL `SELECT` list to specific columns. This avoids hydrating large unused text or JSON fields when performing lightweight queries: + +```go +// Hydrate only id, name, and email fields +summaryUsers := s.userRepo.FindAllByStatus("ACTIVE", + WithSelect("id, name, email"), +) +``` + +### `WithOmit(columns ...string)` + +Excludes specific columns from model hydration: + +```go +// Exclude sensitive or bulky fields +publicUsers := s.userRepo.FindAllByStatus("ACTIVE", + WithOmit("password_hash", "security_token", "biography"), +) +``` + +### `WithDistinct(args ...any)` + +Applies the `DISTINCT` keyword to query results: + +```go +// Retrieve unique departments +var departments []string +s.userRepo.Query(WithDistinct("department")).Pluck("department", &departments) +``` + +--- + +## 6. Dynamic Filtering & Aggregations + +While method names like `FindByNameAndAgeGreaterThan` handle static criteria, business logic often requires dynamic conditions based on optional user filters. + +### `WithWhere(query any, args ...any)` & `WithOr(query any, args ...any)` + +Injects parameterized WHERE/OR clauses safely: + +```go +func (s *UserService) SearchUsers(role string, minAge *uint, maxAge *uint) []User { + filters := []OrmQueryFunc{} + + if minAge != nil { + filters = append(filters, WithWhere("age >= ?", *minAge)) + } + if maxAge != nil { + filters = append(filters, WithWhere("age <= ?", *maxAge)) } + + return s.userRepo.FindAllByRole(role, filters...) +} +``` + +### `WithGroup(name string)` & `WithHaving(query any, args ...any)` + +Enables SQL aggregation pipelines: + +```go +type DepartmentCount struct { + Department string + Count int } + +var results []DepartmentCount +s.userRepo.Query( + WithSelect("department, count(*) as count"), + WithGroup("department"), + WithHaving("count(*) >= ?", 5), +).Scan(&results) +``` + +--- + +## 7. Soft Deletes & Table Overrides + +### `WithUnscoped()` + +GORM automatically filters out soft-deleted records (`WHERE deleted_at IS NULL`). When building administrative recovery tools or auditing workflows, use `WithUnscoped()` to include deleted records: + +```go +// Find a user including soft-deleted entries +deletedUser := s.userRepo.FindById(userId, WithUnscoped()) + +// Find all soft-deleted records +allHistoricalUsers := s.userRepo.FindAllByRole("MEMBER", + WithUnscoped(), + WithWhere("deleted_at IS NOT NULL"), +) ``` -You can then apply this helper to your repository calls dynamically: +### `WithTable(name string, args ...any)` + +Redirects the query to an alternate table or partition while retaining the model's schema mapping: ```go -func (s *UserService) GetRecentUsers(page int) []*User { - // The repository method remains generic, but the service dictates the limits! - users, _ := s.userRepo.FindAllByOrderByCreatedAtDesc( - utils.Paginate(page, 20), +// Query an archive table for older records +archivedUsers := s.userRepo.FindAllByStatus("INACTIVE", + WithTable("users_archive_2025"), +) +``` + +--- + +## 8. Composing Reusable Query Specifications (`Combine`) + +`Combine` merges multiple `OrmQueryFunc` helpers into a single composite function. This lets you encapsulate common business scopes into clean, reusable functions: + +```go +package scopes + +import ( + . "github.com/BeamFoundry/rays-orm/pkg/core" + . "github.com/BeamFoundry/rays-orm/pkg/util" +) + +// ActiveRecentUsers encapsulates three distinct query behaviors +func ActiveRecentUsers(page int) OrmQueryFunc { + return Combine( + WithWhere("status = ?", "ACTIVE"), + OrderByDesc("created_at"), + Paginate(page, 20), ) - return users +} + +// In the service layer: +func (s *UserService) GetDashboardUsers(page int) []User { + return s.userRepo.FindAll(scopes.ActiveRecentUsers(page)) } ``` -## 3. Raw SQL & Complex Queries +--- + +## 9. Direct GORM Querying (`repo.Query()`) -If you hit the limits of the generated query builder (e.g., complex subqueries, massive data aggregations, -or highly specific database vendor features), you can bypass the magic method generation entirely. +If you require raw SQL, subqueries, or complex joins that exceed auto-generated methods, call `repo.Query(queries ...OrmQueryFunc)`. -Every repository embedding `OrmRepository` exposes a `Query()` method. This returns the raw `*gorm.DB` object -mapped to the current Model, correctly bound to any active `ThreadLocal` transaction. +This returns a native GORM `*gorm.DB` instance bound to the model and automatically enrolled in any active **ThreadLocal** transaction: ```go package repositories import ( . "github.com/BeamFoundry/rays-orm/pkg/core" - . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays-orm/pkg/util" ) type AnalyticsRepository struct { OrmRepository[User, uint] } -// Manually implementing a complex method -func (r *AnalyticsRepository) GetAverageAgeByRole(role string) float64 { - var average float64 +func (r *AnalyticsRepository) GetAverageSalaryByDepartment(dept string) float64 { + var avg float64 - // r.Query() returns *gorm.DB mapped to the User table - r.Query(). - Select("AVG(age)"). - Where("role = ?", role). - Scan(&average) + // r.Query() returns *gorm.DB scoped to User and active transactions + r.Query( + WithWhere("department = ?", dept), + WithUnscoped(), + ). + Select("AVG(salary)"). + Scan(&avg) - return average + return avg } ``` diff --git a/pkg/util/query_func.go b/pkg/util/query_func.go new file mode 100644 index 0000000..22ef17a --- /dev/null +++ b/pkg/util/query_func.go @@ -0,0 +1,188 @@ +package util + +import ( + "math" + + . "github.com/BeamFoundry/rays-orm/pkg/core" + . "github.com/BeamFoundry/rays/pkg/lang" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// DefaultPageSize is the default number of items per page when pageSize <= 0 in Paginate. +const DefaultPageSize = 10 + +// Paginate returns an OrmQueryFunc that calculates offset and limit from a 1-indexed page number and page size. +// If page <= 0, page defaults to 1. +// If pageSize <= 0, pageSize defaults to DefaultPageSize. +// If (page - 1) * pageSize exceeds the maximum integer value, a query error is set on the database query. +func Paginate(page, pageSize int) OrmQueryFunc { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = DefaultPageSize + } + return func(db *gorm.DB) *gorm.DB { + p := page - 1 + if p > math.MaxInt/pageSize { + tx := db.Session(&gorm.Session{}) + err := NewError("PaginationOverflowError", "pagination offset overflow", "page", page, "pageSize", pageSize) + if addErr := tx.AddError(err); addErr != nil { + return tx + } + return tx + } + offset := p * pageSize + if offset == 0 { + return db.Offset(-1).Limit(pageSize) + } + return db.Offset(offset).Limit(pageSize) + } +} + +// WithLimit returns an OrmQueryFunc that limits the number of results returned by the query. +func WithLimit(limit int) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + tx := db.Limit(limit) + if c, ok := tx.Statement.Clauses["LIMIT"]; ok { + if l, ok := c.Expression.(clause.Limit); ok { + l.Limit = &limit + c.Expression = l + tx.Statement.Clauses["LIMIT"] = c + } + } + return tx + } +} + +// Limit is an alias for WithLimit. +func Limit(limit int) OrmQueryFunc { + return WithLimit(limit) +} + +// WithOffset returns an OrmQueryFunc that sets the number of records to skip before starting to return records. +func WithOffset(offset int) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + if offset == 0 { + return db.Offset(-1) + } + return db.Offset(offset) + } +} + +// WithPreload returns an OrmQueryFunc that eagerly loads an association or relationship. +func WithPreload(query string, args ...any) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Preload(query, args...) + } +} + +// WithJoins returns an OrmQueryFunc that specifies Joins conditions for eager loading or query filtering. +func WithJoins(query string, args ...any) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Joins(query, args...) + } +} + +// WithOrder returns an OrmQueryFunc that specifies ordering rules for retrieving records. +func WithOrder(value any) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Order(value) + } +} + +// OrderByAsc returns an OrmQueryFunc that sorts results by the given column in ascending order. +func OrderByAsc(column string) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Order(clause.OrderByColumn{ + Column: clause.Column{Name: column}, + Desc: false, + }) + } +} + +// OrderByDesc returns an OrmQueryFunc that sorts results by the given column in descending order. +func OrderByDesc(column string) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Order(clause.OrderByColumn{ + Column: clause.Column{Name: column}, + Desc: true, + }) + } +} + +// WithSelect returns an OrmQueryFunc that specifies fields to retrieve when querying the database. +func WithSelect(query any, args ...any) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Select(query, args...) + } +} + +// WithOmit returns an OrmQueryFunc that specifies columns to omit from the query. +func WithOmit(columns ...string) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Omit(columns...) + } +} + +// WithDistinct returns an OrmQueryFunc that specifies distinct fields from the query. +func WithDistinct(args ...any) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Distinct(args...) + } +} + +// WithWhere returns an OrmQueryFunc that adds WHERE conditions to the query. +func WithWhere(query any, args ...any) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Where(query, args...) + } +} + +// WithOr returns an OrmQueryFunc that adds OR conditions to the query. +func WithOr(query any, args ...any) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Or(query, args...) + } +} + +// WithGroup returns an OrmQueryFunc that specifies the GROUP BY clause for the query. +func WithGroup(name string) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Group(name) + } +} + +// WithHaving returns an OrmQueryFunc that specifies the HAVING clause for the query. +func WithHaving(query any, args ...any) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Having(query, args...) + } +} + +// WithUnscoped returns an OrmQueryFunc that ignores soft-deleted conditions when querying. +func WithUnscoped() OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Unscoped() + } +} + +// WithTable returns an OrmQueryFunc that specifies the table to query. +func WithTable(name string, args ...any) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Table(name, args...) + } +} + +// Combine combines multiple OrmQueryFunc into a single OrmQueryFunc executed sequentially. +func Combine(funcs ...OrmQueryFunc) OrmQueryFunc { + return func(db *gorm.DB) *gorm.DB { + for _, f := range funcs { + if f != nil { + db = f(db) + } + } + return db + } +} diff --git a/pkg/util/query_func_test.go b/pkg/util/query_func_test.go new file mode 100644 index 0000000..693e719 --- /dev/null +++ b/pkg/util/query_func_test.go @@ -0,0 +1,285 @@ +package util_test + +import ( + "errors" + "math" + "sync" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + . "github.com/BeamFoundry/rays-orm/pkg/util" + . "github.com/BeamFoundry/rays/pkg/lang" +) + +func TestUtil(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Pkg/Util Suite") +} + +//nolint:govet // Entity for testing +type Animal struct { + ID uint + Name string + UserID uint +} + +//nolint:govet // Entity for testing +type User struct { + ID uint + Name string + UserName string + Age uint + Animals []Animal + DeletedAt gorm.DeletedAt + CreatedAt time.Time + UpdatedAt time.Time +} + +var _ = Describe("OrmQueryFunc Utilities", func() { + var db *gorm.DB + + BeforeEach(func() { + var err error + db, err = gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + Expect(err).NotTo(HaveOccurred()) + sqlDB, err := db.DB() + Expect(err).NotTo(HaveOccurred()) + sqlDB.SetMaxOpenConns(1) + Expect(db.AutoMigrate(&User{}, &Animal{})).NotTo(HaveOccurred()) + + users := []User{ + {Name: "Alice", UserName: "alice", Age: 20}, + {Name: "Bob", UserName: "bob", Age: 30}, + {Name: "Charlie", UserName: "charlie", Age: 40}, + {Name: "Alice", UserName: "alice2", Age: 25}, + } + Expect(db.Create(&users).Error).NotTo(HaveOccurred()) + + animal := Animal{Name: "Fluffy", UserID: users[0].ID} + Expect(db.Create(&animal).Error).NotTo(HaveOccurred()) + }) + + It("paginates results correctly", func() { + var page1 []User + Expect(db.Scopes(Paginate(1, 2), OrderByAsc("id")).Find(&page1).Error).NotTo(HaveOccurred()) + Expect(page1).To(HaveLen(2)) + Expect(page1[0].UserName).To(Equal("alice")) + Expect(page1[1].UserName).To(Equal("bob")) + + var page2 []User + Expect(db.Scopes(Paginate(2, 2), OrderByAsc("id")).Find(&page2).Error).NotTo(HaveOccurred()) + Expect(page2).To(HaveLen(2)) + Expect(page2[0].UserName).To(Equal("charlie")) + Expect(page2[1].UserName).To(Equal("alice2")) + + // Page <= 0 defaults to 1, pageSize <= 0 defaults to DefaultPageSize + var pageDefault []User + Expect(db.Scopes(Paginate(0, 0), OrderByAsc("id")).Find(&pageDefault).Error).NotTo(HaveOccurred()) + Expect(pageDefault).To(HaveLen(4)) + }) + + It("supports concurrent Paginate usage without race conditions", func() { + fn := Paginate(0, 0) + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer GinkgoRecover() + defer wg.Done() + var users []User + Expect(db.Scopes(fn).Find(&users).Error).NotTo(HaveOccurred()) + Expect(users).To(HaveLen(4)) + }() + } + wg.Wait() + }) + + It("returns an error on pagination offset overflow", func() { + var users []User + err := db.Scopes(Paginate(math.MaxInt, 10)).Find(&users).Error + Expect(err).To(HaveOccurred()) + + var langErr Error + Expect(errors.As(err, &langErr)).To(BeTrue()) + Expect(langErr.Name()).To(Equal("PaginationOverflowError")) + Expect(langErr.Message()).To(Equal("pagination offset overflow")) + + err2 := db.Scopes(Paginate(math.MaxInt/2+2, 2)).Find(&users).Error + Expect(err2).To(HaveOccurred()) + var langErr2 Error + Expect(errors.As(err2, &langErr2)).To(BeTrue()) + Expect(langErr2.Name()).To(Equal("PaginationOverflowError")) + }) + + It("does not leave reusable db handle in error state on pagination offset overflow", func() { + fn := Paginate(math.MaxInt, 10) + tx := fn(db) + Expect(tx.Error).To(HaveOccurred()) + Expect(db.Error).ToNot(HaveOccurred()) + + // A subsequent query using db should still work + var users []User + Expect(db.Find(&users).Error).NotTo(HaveOccurred()) + Expect(users).To(HaveLen(4)) + }) + + It("limits results with Limit and WithLimit", func() { + var users []User + Expect(db.Scopes(Limit(2)).Find(&users).Error).NotTo(HaveOccurred()) + Expect(users).To(HaveLen(2)) + + var usersWithLimit []User + Expect(db.Scopes(WithLimit(2)).Find(&usersWithLimit).Error).NotTo(HaveOccurred()) + Expect(usersWithLimit).To(HaveLen(2)) + }) + + It("enforces zero rows when combining scopes with WithLimit(0)", func() { + var users []User + Expect(db.Scopes(WithLimit(10), WithLimit(0)).Find(&users).Error).NotTo(HaveOccurred()) + Expect(users).To(BeEmpty()) + + var usersWithOffset []User + Expect(db.Scopes(WithOffset(1), WithLimit(10), WithLimit(0)).Find(&usersWithOffset).Error).NotTo(HaveOccurred()) + Expect(usersWithOffset).To(BeEmpty()) + + var usersOverrideZero []User + Expect(db.Scopes(WithLimit(0), WithLimit(2)).Find(&usersOverrideZero).Error).NotTo(HaveOccurred()) + Expect(usersOverrideZero).To(HaveLen(2)) + }) + + It("offsets results with WithOffset", func() { + var users []User + Expect(db.Scopes(WithOffset(2), OrderByAsc("id")).Find(&users).Error).NotTo(HaveOccurred()) + Expect(users).To(HaveLen(2)) + Expect(users[0].UserName).To(Equal("charlie")) + }) + + It("cancels existing offset when zero offset is requested in WithOffset and Paginate", func() { + var users []User + // WithOffset(0) cancels prior offset + Expect(db.Offset(2).Scopes(WithOffset(0), OrderByAsc("id")).Find(&users).Error).NotTo(HaveOccurred()) + Expect(users).To(HaveLen(4)) + Expect(users[0].UserName).To(Equal("alice")) + + // Paginate(1, 2) cancels prior offset + var page1 []User + Expect(db.Offset(2).Scopes(Paginate(1, 2), OrderByAsc("id")).Find(&page1).Error).NotTo(HaveOccurred()) + Expect(page1).To(HaveLen(2)) + Expect(page1[0].UserName).To(Equal("alice")) + Expect(page1[1].UserName).To(Equal("bob")) + + // Paginate(2, 2) sets new nonzero offset + var page2 []User + Expect(db.Offset(1).Scopes(Paginate(2, 2), OrderByAsc("id")).Find(&page2).Error).NotTo(HaveOccurred()) + Expect(page2).To(HaveLen(2)) + Expect(page2[0].UserName).To(Equal("charlie")) + Expect(page2[1].UserName).To(Equal("alice2")) + }) + + It("preloads associations", func() { + var user User + Expect(db.Scopes(WithPreload("Animals")).First(&user, "user_name = ?", "alice").Error).NotTo(HaveOccurred()) + Expect(user.Animals).To(HaveLen(1)) + Expect(user.Animals[0].Name).To(Equal("Fluffy")) + }) + + It("joins associations with WithJoins", func() { + var users []User + Expect(db.Scopes(WithJoins("JOIN animals ON animals.user_id = users.id")).Find(&users).Error).NotTo(HaveOccurred()) + Expect(users).To(HaveLen(1)) + Expect(users[0].UserName).To(Equal("alice")) + }) + + It("orders results with WithOrder, OrderByAsc, and OrderByDesc", func() { + var usersAsc []User + Expect(db.Scopes(OrderByAsc("age")).Find(&usersAsc).Error).NotTo(HaveOccurred()) + Expect(usersAsc[0].Age).To(BeNumerically("==", 20)) + + var usersDesc []User + Expect(db.Scopes(OrderByDesc("age")).Find(&usersDesc).Error).NotTo(HaveOccurred()) + Expect(usersDesc[0].Age).To(BeNumerically("==", 40)) + + var usersCustom []User + Expect(db.Scopes(WithOrder("age DESC, user_name ASC")).Find(&usersCustom).Error).NotTo(HaveOccurred()) + Expect(usersCustom[0].Age).To(BeNumerically("==", 40)) + }) + + It("selects specific fields with WithSelect", func() { + var users []User + Expect(db.Scopes(WithSelect("name")).Find(&users).Error).NotTo(HaveOccurred()) + Expect(users[0].Name).NotTo(BeEmpty()) + Expect(users[0].Age).To(BeNumerically("==", 0)) + }) + + It("omits specific fields with WithOmit", func() { + var users []User + Expect(db.Scopes(WithOmit("age")).Find(&users).Error).NotTo(HaveOccurred()) + Expect(users[0].Name).NotTo(BeEmpty()) + Expect(users[0].Age).To(BeNumerically("==", 0)) + }) + + It("retrieves distinct values with WithDistinct", func() { + var names []string + Expect(db.Model(&User{}).Scopes(WithDistinct("name")).Pluck("name", &names).Error).NotTo(HaveOccurred()) + Expect(names).To(HaveLen(3)) + }) + + It("filters with WithWhere and WithOr", func() { + var users []User + Expect(db.Scopes(WithWhere("age = ?", 20), WithOr("age = ?", 30)).Find(&users).Error).NotTo(HaveOccurred()) + Expect(users).To(HaveLen(2)) + }) + + It("groups and filters with WithGroup and WithHaving", func() { + type Result struct { + Name string + Count int + } + var results []Result + Expect(db.Model(&User{}). + Select("name, count(*) as count"). + Scopes(WithGroup("name"), WithHaving("count(*) > ?", 1)). + Scan(&results).Error).NotTo(HaveOccurred()) + + Expect(results).To(HaveLen(1)) + Expect(results[0].Name).To(Equal("Alice")) + Expect(results[0].Count).To(Equal(2)) + }) + + It("handles soft deletes with WithUnscoped", func() { + var user User + Expect(db.First(&user, "user_name = ?", "alice").Error).NotTo(HaveOccurred()) + Expect(db.Delete(&user).Error).NotTo(HaveOccurred()) + + var normalUsers []User + Expect(db.Find(&normalUsers).Error).NotTo(HaveOccurred()) + Expect(normalUsers).To(HaveLen(3)) + + var allUsers []User + Expect(db.Scopes(WithUnscoped()).Find(&allUsers).Error).NotTo(HaveOccurred()) + Expect(allUsers).To(HaveLen(4)) + }) + + It("queries with WithTable", func() { + var count int64 + Expect(db.Scopes(WithTable("users")).Count(&count).Error).NotTo(HaveOccurred()) + Expect(count).To(BeNumerically("==", 4)) + }) + + It("combines multiple query functions with Combine", func() { + combined := Combine( + OrderByAsc("age"), + Limit(2), + ) + var users []User + Expect(db.Scopes(combined).Find(&users).Error).NotTo(HaveOccurred()) + Expect(users).To(HaveLen(2)) + Expect(users[0].Age).To(BeNumerically("==", 20)) + Expect(users[1].Age).To(BeNumerically("==", 25)) + }) +}) diff --git a/rays_orm.go b/rays_orm.go index 7ab05b8..da67431 100644 --- a/rays_orm.go +++ b/rays_orm.go @@ -14,6 +14,7 @@ // This project divides its code between the following packages : // // - [github.com/BeamFoundry/rays-orm/pkg/core]: Contains all the repository interfaces and objects. +// - [github.com/BeamFoundry/rays-orm/pkg/util]: Contains query helpers and OrmQueryFunc utilities. package rays_orm //nolint:revive // Ignoring package naming hint import (