
### Advanced Querying with Predicates

The SessionManager provides a rich predicate system that transforms simple keyword arguments into complex SQL queries. Instead of writing raw SQL, you pass predicate objects as values to filter methods.

All examples assume you are running inside an active SessionManager session context (e.g. via a service-level decorator like `@SessionManager.with_sync_session`, or `async with SessionManager.using_async_session(): ...`).

In most application code, prefer the convenience methods on your SQLModel classes (e.g. `MyModel.get(...)`, `MyModel.sort(...)`) over calling `SessionManager.async_get(...)` / `SessionManager.async_sort(...)` directly.

**Basic Usage Pattern:**
```python
# Simple equality (returns a single row by default; mode="one")
item = await MyModel.get(id="my_id")

# Fetch multiple rows explicitly
results = await MyModel.get(status="active", mode="all")

# Using predicates for complex conditions
results = await MyModel.get(
    mode="all",
    age=SessionManager.gt(18),           # age > 18
    name=SessionManager.ilike("%john%")  # case-insensitive LIKE
)
```

**Available Predicates:**

| Predicate | SQL Equivalent | Example |
|-----------|----------------|---------|
| `gt(value)` | `>` | `age=SessionManager.gt(18)` |
| `ge(value)` | `>=` | `age=SessionManager.ge(18)` |
| `lt(value)` | `<` | `age=SessionManager.lt(65)` |
| `le(value)` | `<=` | `age=SessionManager.le(65)` |
| `ne(value)` | `!=` | `status=SessionManager.ne("deleted")` |
| `between(low, high)` | `BETWEEN` | `age=SessionManager.between(18, 65)` |
| `like(pattern)` | `LIKE` | `name=SessionManager.like("John%")` |
| `ilike(pattern)` | `ILIKE` | `name=SessionManager.ilike("%john%")` |
| `in_(list)` | `IN` | `status=SessionManager.in_(["active", "pending"])` |
| `notin_(list)` | `NOT IN` | `status=SessionManager.notin_(["deleted", "archived"])` |

**Regex Predicate (PostgreSQL-specific):**
```python
# Basic regex (case-sensitive)
results = await MyModel.get(
    mode="all",
    email=SessionManager.regex(r".*@example\.com$")
)

# Case-insensitive regex
results = await MyModel.get(
    mode="all",
    name=SessionManager.regex(r"john", case_sensitive=False)
)

# Match any of multiple patterns
results = await MyModel.get(
    mode="all",
    tag=SessionManager.regex(["python", "javascript"], match_mode="any")
)

# Match all patterns
results = await MyModel.get(
    mode="all",
    description=SessionManager.regex(["urgent", "critical"], match_mode="all")
)

# Reverse matching: check if column pattern matches provided value
# Useful when column contains regex patterns
results = await MyModel.get(
    mode="all",
    url_pattern=SessionManager.regex("/api/users/123", pattern_in_column=True)
)
```

**Full-Text Search Predicate:**
```python
# Basic full-text search
results = await MyModel.get(
    mode="all",
    content=SessionManager.full_text_search("machine learning")
)

# Web search mode (supports operators like AND, OR, quotes)
results = await MyModel.get(
    mode="all",
    content=SessionManager.full_text_search(
        '"exact phrase" OR keyword',
        web_search_mode=True
    )
)

# Search within JSONB field
results = await MyModel.get(
    mode="all",
    metadata=SessionManager.full_text_search(
        "search term",
        jsonb_key="description"  # searches metadata->description
    )
)

# Search entire JSONB object
results = await MyModel.get(
    mode="all",
    metadata=SessionManager.full_text_search(
        "search term",
        jsonb_key="*"  # searches all text values in the JSONB
    )
)

# Different language
results = await MyModel.get(
    mode="all",
    content=SessionManager.full_text_search("recherche", language="french")
)
```

**📍 Code Pointers:**
- Predicate base class: `session_manager.py` → `class Predicate`
- Predicate implementations: `session_manager.py` → `class gt`, `class ge`, `class lt`, `class le`, `class between`, `class ne`, `class like`, `class ilike`, `class regex`, `class in_`, `class notin_`, `class as_jsonb`, `class full_text_search`
- Predicate resolution: `session_manager.py` → `SessionManager.build_predicate()`

### Working with JSONB Fields

The SessionManager supports querying JSONB columns using specialized JSONB predicates.

**⚠️ Important: Use JSONB Predicates, Not Direct Equality**

While `SessionManager.get_model_attr()` supports dot notation for JSONB paths (e.g., `"settings.theme"`), using direct equality comparisons **does not work correctly**:

```python
# ❌ BROKEN - Don't do this!
# This generates: (settings -> 'theme') = 'dark'
# which compares JSONB to text and fails or gives wrong results
results = await Config.get(mode="all", **{
    "settings.theme": "dark",  # JSONB vs text comparison - BAD
})

# ✅ CORRECT - Use `as_jsonb(...)` for scalar comparisons on JSONB paths
results = await Config.get(mode="all", **{
    "settings.theme": SessionManager.as_jsonb("dark"),
    "settings.notifications.email": SessionManager.as_jsonb(True),
    "settings.items.0.name": SessionManager.as_jsonb("first"),
})

# ✅ CORRECT - Or use jsonb_contains instead (often preferable for matching structures)
results = await Config.get(
    mode="all",
    settings=SessionManager.jsonb_contains({"theme": "dark"})
)
```

The issue is that `settings -> 'theme'` returns a JSONB value, not text. PostgreSQL cannot directly compare JSONB to scalar types like `'dark'` or `True`.

**JSONB Containment Predicates (Recommended):**
```python
# Check if JSONB contains a structure (PostgreSQL @> operator)
results = await Config.get(
    mode="all",
    settings=SessionManager.jsonb_contains({"active": True})
)

# Check nested structures
results = await Config.get(
    mode="all",
    settings=SessionManager.jsonb_contains({
        "notifications": {"email": True, "sms": False}
    })
)

# Inverse: check if JSONB is contained by a structure (<@ operator)
results = await Config.get(
    mode="all",
    settings=SessionManager.jsonb_contained_by({
        "active": True,
        "theme": "dark",
        "extra_allowed_field": "value"
    })
)
```

**JSONB Key Existence Predicates:**

⚠️ Note: these predicates check keys on the JSONB value itself (top-level). For nested keys, prefer `jsonb_contains(...)` or dot-path queries with `SessionManager.as_jsonb(...)`.

```python
# Check if a key exists (PostgreSQL ? operator)
results = await Config.get(
    mode="all",
    settings=SessionManager.jsonb_has_key("notifications")
)

# Check if ANY of the keys exist (?| operator)
results = await Config.get(
    mode="all",
    settings=SessionManager.jsonb_has_any_keys(["email", "phone", "slack"])
)

# Check if ALL keys exist (?& operator)
results = await Config.get(
    mode="all",
    settings=SessionManager.jsonb_has_all_keys(["email", "notifications"])
)
```

**📍 Code Pointers:**
- JSONB path resolution: `session_manager.py` → `SessionManager.get_model_attr()` (builds path expressions, but don't use with direct equality)
- JSONB predicates: `session_manager.py` → `class jsonb_contains`, `class jsonb_contained_by`, `class jsonb_has_key`, `class jsonb_has_any_keys`, `class jsonb_has_all_keys`
- Why direct equality fails: `->` returns JSONB, so either compare JSONB-to-JSONB (e.g. `SessionManager.as_jsonb(...)`) or use explicit casting.

### Join Models for Cross-Table Queries

The SessionManager supports joining tables in queries, allowing you to filter based on related model fields.

**Basic Join Pattern:**
```python
# Given two related models:
class User(BaseSQLModel, table=True):
    id: str
    name: str

class Order(BaseSQLModel, table=True):
    id: str
    user_id: str = Field(foreign_key="user.id")
    amount: float

# Query Orders with User filters using double-underscore convention
results = await Order.get(
    mode="all",
    join_models=[User],
    user__name="John",           # Filter on joined User.name
    amount=SessionManager.gt(100) # Filter on Order.amount
)
```

**The Double-Underscore Convention:**
When filtering on joined tables, prefix the field name with `{tablename}__`:
- `user__name="John"` → filters on `User.name`
- `user__status=SessionManager.in_(["active", "premium"])` → predicates work too
- `order__created_at=SessionManager.gt(yesterday)` → if Order was joined

**Custom Join Conditions:**
```python
# When the relationship isn't automatic, provide explicit join condition
results = await Order.get(
    mode="all",
    join_models=[
        (User, Order.user_id == User.id)  # Tuple: (model, condition)
    ],
    user__name="John"
)

# Multiple joins
results = await OrderItem.get(
    mode="all",
    join_models=[
        Order,                                    # Auto-detect relationship
        (User, Order.user_id == User.id)         # Explicit condition
    ],
    order__status="completed",
    user__tier="premium"
)
```

**Sorting with Joins:**
```python
# Use sort() with join_models for sorted results
results = await Order.sort(
    join_models=[User],
    user__status="active",
    created_at=SessionManager.descending()  # Sort by Order.created_at DESC
)
```

**📍 Code Pointers:**
- Join handling in get_stmt: `session_manager.py` → `class get_stmt` (look for `join_models` parameter)
- Join handling in sort_stmt: `session_manager.py` → `class sort_stmt` (look for `join_models` parameter)
- Tablename prefix convention: `{tablename}__` (e.g., `user__name` for `User.name`)

### Sorting and Pagination

**Simple Sorting:**
```python
# Using the simple_sort convenience method on BaseSQLModel
results = await MyModel.simple_sort(
    field="created_at",
    descending=True,
    limit=10,
    offset=20,
    status="active"  # Additional filter
)
```

**Advanced Sorting with SortOrder:**
```python
# Multiple sort criteria with limits
results = await MyModel.sort(
    status="active",
    created_at=SessionManager.descending(),  # Primary sort
    name=SessionManager.ascending()          # Secondary sort
)

# Sort with limit embedded in the sort order
sort_order = SessionManager.descending()
sort_order.limit = 50
results = await MyModel.sort(
    created_at=sort_order
)
```

**Pagination:**
```python
# Offset and limit work with both get() and sort()
page_2 = await MyModel.get(
    status="active",
    mode="all",
    offset=20,  # Skip first 20
    limit=10    # Return 10 items
)
```

**Selecting Specific Columns:**
```python
# Return only specific columns (returns dicts, not model instances)
results = await MyModel.get(
    mode="all",
    select_columns=["id", "name", "created_at"],
    status="active"
)
# results = [{"id": "...", "name": "...", "created_at": ...}, ...]

# Can also use model attributes
results = await MyModel.get(
    mode="all",
    select_columns=[MyModel.id, MyModel.name]
)
```

**📍 Code Pointers:**
- SortOrder classes: `session_manager.py` → `class SortOrder`, `class descending`, `class ascending`
- sort_stmt builder: `session_manager.py` → `class sort_stmt`
- simple_sort on BaseSQLModel: `base_models.py` → `BaseSQLModel.simple_sort()`

### Custom Predicates and Sort Orders

When the built-in predicate system isn't enough, you can pass raw SQLAlchemy expressions using `custom_predicates` and `custom_sort_orders`. These are escape hatches for complex queries that can't be expressed with kwargs.

**Custom Predicates:**

The `custom_predicates` parameter accepts a list of SQLAlchemy expressions that get AND-ed into the WHERE clause.

```python
from sqlalchemy import or_, and_, func

# Complex OR conditions that can't be expressed with kwargs
results = await MyModel.get(
    mode="all",
    custom_predicates=[
        or_(
            MyModel.status == "active",
            and_(
                MyModel.status == "pending",
                MyModel.created_at > some_date
            )
        )
    ]
)

# Using SQL functions in predicates
results = await MyModel.get(
    mode="all",
    custom_predicates=[
        func.length(MyModel.name) > 5,
        func.lower(MyModel.email).like("%@example.com")
    ],
    status="active"  # Can combine with regular kwargs
)

# Subqueries
from sqlalchemy import select, exists
subq = select(OtherModel.id).where(OtherModel.ref_id == MyModel.id)
results = await MyModel.get(
    mode="all",
    custom_predicates=[
        exists(subq)  # Only return MyModel rows that have related OtherModel
    ]
)
```

**Custom Sort Orders:**

The `custom_sort_orders` parameter accepts a list of SQLAlchemy order expressions. Each item can be:
- A raw order expression (e.g., `desc(MyModel.created_at)`)
- A tuple of `(order_expression, limit)` where limit is optional

```python
from sqlalchemy import desc, asc, func, nulls_last

# Complex sorting with SQL functions
results = await MyModel.sort(
    status="active",
    custom_sort_orders=[
        desc(func.coalesce(MyModel.priority, 0)),  # NULL priorities treated as 0
        asc(MyModel.name)
    ]
)

# NULLS LAST sorting
results = await MyModel.sort(
    custom_sort_orders=[
        nulls_last(desc(MyModel.updated_at))
    ]
)

# Combining with regular SortOrder kwargs
results = await MyModel.sort(
    created_at=SessionManager.descending(),  # Regular sort
    custom_sort_orders=[
        asc(func.lower(MyModel.name))         # Additional custom sort
    ]
)

# Custom sort with limit (tuple form)
results = await MyModel.sort(
    custom_sort_orders=[
        (desc(MyModel.score), 10)  # Sort by score, limit to 10
    ]
)
```

**Combining Everything:**

```python
# A complex query using all features
results = await Order.get(
    mode="all",
    join_models=[User],
    user__status="active",                        # Join predicate via kwargs
    amount=SessionManager.gt(100),                # Regular predicate
    custom_predicates=[
        or_(
            Order.priority == "high",
            Order.created_at > cutoff_date
        ),
        func.jsonb_array_length(Order.items) > 0  # Raw SQL function
    ],
    offset=20,
    limit=10
)
```

**📍 Code Pointers:**
- custom_predicates in get_stmt: `session_manager.py` → `class get_stmt.build()` (appended to predicates list)
- custom_predicates in sort_stmt: `session_manager.py` → `class sort_stmt.build()` (appended to predicates list)
- custom_sort_orders: `session_manager.py` → `class sort_stmt.build()` (merged with kwargs-based sort orders)

### Batch Updates

The `batch_update` method executes a single `UPDATE ... SET ... WHERE ...` statement, avoiding the N+1 pattern of fetching rows and updating them individually.

**Basic Usage:**
```python
# Update all active users to inactive in one query
count = await User.batch_update(
    get_kwargs=dict(status="active"),
    status="inactive",
)
# count = number of rows updated

# Multiple SET values
count = await MyModel.batch_update(
    get_kwargs=dict(
        role="moderator",
        status=SessionManager.ne("deleted"),
    ),
    status="suspended",
    suspended_at=datetime.utcnow(),
)
```

**With Predicates in the WHERE clause:**
```python
# All predicates from get() work in get_kwargs
count = await MyModel.batch_update(
    get_kwargs=dict(
        age=SessionManager.gt(18),
        role=SessionManager.in_(["admin", "moderator"]),
    ),
    verified=True,
)
```

**With Custom Predicates:**
```python
from sqlalchemy import or_

count = await MyModel.batch_update(
    get_kwargs=dict(status="active"),
    custom_predicates=[
        or_(MyModel.score < 10, MyModel.flagged == True)
    ],
    status="review",
)
```

**Controlling Commit Behavior:**
```python
# Skip auto-commit when batching multiple operations
count = await MyModel.batch_update(
    get_kwargs=dict(status="active"),
    do_commit=False,  # Caller manages the commit
    status="migrated",
)
# ... do more work ...
await SessionManager.async_commit_session()
```

**Return Value:**
Returns an `int` — the number of rows affected by the UPDATE statement.

**⚠️ Important Notes:**
- `batch_update` does **not** run Pydantic validation on the update values. If you need per-row validation, use the instance-level `update()` method instead.
- The `get_kwargs` dict uses the same predicate system as `MyModel.get(...)`.
- The `**update_values` are scalar column assignments (the SET clause).

**📍 Code Pointers:**
- Statement builder: `session_manager.py` → `class batch_update_stmt`
- Async executor: `session_manager.py` → `SessionManager.async_batch_update()`
- Sync executor: `session_manager.py` → `SessionManager.batch_update()`
- Convenience method: `base_models.py` → `BaseSQLModel.batch_update()`
