Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
339 changes: 281 additions & 58 deletions docs/content/docs/7-advanced-querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```
Loading
Loading