Skip to content
Merged
8 changes: 4 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,6 @@ Items marked with :fire: are high priority.
data.
- add more tests where 'auto_commit' is set to False to ensure that commit is
not called automatically.
- :fire: Extend `with_count()` to support full multi-segment relationship
paths (mixed reverse FK + forward/reverse M2M) with correct distinct
semantics, zero-row preservation via LEFT JOINs, and reusable join planning
across multiple count annotations.
- :fire: perhaps add a `JSON` field type to allow storing JSON data in a field,
and an `Object` field type to allow storing arbitrary Python objects? Perhaps
a `Binary` field type to allow storing arbitrary binary data? (just uses the
Expand All @@ -51,6 +47,10 @@ Items marked with :fire: are high priority.
- Refactor filter condition handling to use one centralized builder path and
keep validation/SQL assembly behavior in sync across code paths
(issue #136).
- Support `ForeignKey(..., db_column=...)` consistently across ORM runtime
CRUD/query paths (issue #138). Once closed, rewrite temporary custom-column
regression tests (currently using setup workarounds) to use normal ORM
insert/query flows end-to-end.

## Housekeeping

Expand Down
46 changes: 24 additions & 22 deletions docs/api-reference/query-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,9 @@ def prefetch_related(

**Parameters:**

| Parameter | Type | Description |
| --------- | ----- | --------------------------------------------------- |
| `*paths` | `str` | One or more reverse FK or M2M relationship names |
| Parameter | Type | Description |
| --------- | ----- | ------------------------------------------------ |
| `*paths` | `str` | One or more reverse FK or M2M relationship names |

**Returns:** `Self` for method chaining.

Expand Down Expand Up @@ -391,9 +391,9 @@ def group_by(

**Parameters:**

| Parameter | Type | Description |
| --------- | ----- | ------------------------ |
| `*fields` | `str` | Fields to group results |
| Parameter | Type | Description |
| --------- | ----- | ----------------------- |
| `*fields` | `str` | Fields to group results |

**Returns:** `Self` for method chaining.

Expand All @@ -415,8 +415,8 @@ def annotate(

**Parameters:**

| Parameter | Type | Description |
| -------------- | --------------- | ----------------------------------- |
| Parameter | Type | Description |
| -------------- | --------------- | ------------------------------------ |
| `**aggregates` | `AggregateSpec` | Mapping of output alias to aggregate |

**Returns:** `Self` for method chaining.
Expand All @@ -441,9 +441,9 @@ def having(

**Parameters:**

| Parameter | Type | Description |
| -------------- | ------------- | --------------------------------- |
| `**conditions` | `FilterValue` | HAVING conditions with operators |
| Parameter | Type | Description |
| -------------- | ------------- | -------------------------------- |
| `**conditions` | `FilterValue` | HAVING conditions with operators |

**Returns:** `Self` for method chaining.

Expand Down Expand Up @@ -471,24 +471,26 @@ def with_count(

**Parameters:**

| Parameter | Type | Default | Description |
| ---------- | ------ | --------- | ----------------------------------- |
| `path` | `str` | *required*| Relationship name to count |
| `alias` | `str` | `"count"` | Output alias for the count column |
| `distinct` | `bool` | `False` | Use `COUNT(DISTINCT ...)` |
| Parameter | Type | Default | Description |
| ---------- | ------ | ---------- | --------------------------------- |
| `path` | `str` | *required* | Relationship path to count |
| `alias` | `str` | `"count"` | Output alias for the count column |
| `distinct` | `bool` | `False` | Use `COUNT(DISTINCT ...)` |

**Returns:** `Self` for method chaining.

**Raises:**

- [`InvalidProjectionError`](exceptions.md#invalidprojectionerror) --
For invalid aliases, nested paths, or unresolved M2M SQL metadata.
For invalid aliases, invalid terminal relationship types, or
unresolved M2M SQL metadata.
- [`InvalidRelationshipError`](exceptions.md#invalidrelationshiperror)
-- If `path` is not a valid relationship on the model.

> [!NOTE]
> `with_count()` currently supports a single relationship segment
> (for example, `"books"`) and not nested paths like `"books__reviews"`.
> `with_count()` supports nested paths (for example,
> `"author__books"` or `"articles__tags"`), but the terminal segment
> must be to-many (reverse FK or many-to-many).
> If no `group_by()` exists, SQLiter automatically groups by current
> selected model fields.

Expand Down Expand Up @@ -814,9 +816,9 @@ def update(self, values: dict[str, Any]) -> int:

**Parameters:**

| Parameter | Type | Default | Description |
| --------- | ------------------ | ---------- | ------------------------------- |
| `values` | `dict[str, Any]` | *required* | Field names and their new values |
| Parameter | Type | Default | Description |
| --------- | ---------------- | ---------- | -------------------------------- |
| `values` | `dict[str, Any]` | *required* | Field names and their new values |

**Returns:** `int` -- The number of records updated.

Expand Down
16 changes: 14 additions & 2 deletions docs/guide/aggregates.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,24 @@ rows = (
)
```

```python
# Multi-segment path: forward FK + reverse FK terminal
rows = (
db.select(Book)
.with_count("author__books", alias="author_book_count")
.fetch_dicts()
)
```

## Important Notes

- Projection queries must use `fetch_dicts()`.
- `fetch_all()`, `fetch_one()`, `fetch_first()`, `fetch_last()`, and
`count()` are not available in projection mode.
- Aggregate aliases must be non-empty, unique, and must not conflict
with model field names.
- `with_count()` currently supports a single relationship segment
(for example `"books"`), not nested paths like `"books__reviews"`.
- `with_count()` supports nested relationship paths (for example,
`"author__books"` or `"articles__tags"`), but the terminal segment
must be a to-many relationship (reverse FK or many-to-many).
- Use `distinct=True` when you need unique terminal-row counts across
fan-out joins.
3 changes: 2 additions & 1 deletion docs/tui-demo/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ Define database constraints like unique fields and foreign key relationships.

### [ORM Features](orm.md)

Advanced ORM features including foreign keys, lazy loading, and reverse relationships.
Advanced ORM features including foreign keys, relationship loading, and
`with_count()` projection reporting.

### [Caching](caching.md)

Expand Down
113 changes: 113 additions & 0 deletions docs/tui-demo/orm.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,119 @@ db.close()
- Keep ad-hoc reporting SQL aligned with ORM naming
- Use the same metadata from descriptor and manager access

## with_count() Basics

Use `with_count()` to return relationship counts in projection mode.

```python
# --8<-- [start:with-count-basic]
from sqliter import SqliterDB
from sqliter.orm import BaseDBModel, ForeignKey

class AuthorCountDemo(BaseDBModel):
name: str

class BookCountDemo(BaseDBModel):
title: str
author: ForeignKey[AuthorCountDemo] = ForeignKey(
AuthorCountDemo, related_name="books"
)

db = SqliterDB(memory=True)
db.create_table(AuthorCountDemo)
db.create_table(BookCountDemo)

alice = db.insert(AuthorCountDemo(name="Alice"))
bob = db.insert(AuthorCountDemo(name="Bob"))
db.insert(AuthorCountDemo(name="No Books"))

db.insert(BookCountDemo(title="A1", author=alice))
db.insert(BookCountDemo(title="A2", author=alice))
db.insert(BookCountDemo(title="B1", author=bob))

rows = (
db.select(AuthorCountDemo)
.with_count("books", alias="book_count")
.order("name")
.fetch_dicts()
)

print("Authors with book counts:")
for row in rows:
print(f' {row["name"]}: {row["book_count"]}')

print("\nRows with zero related records are included.")

db.close()
# --8<-- [end:with-count-basic]
```

### What Happens

- `with_count("books")` adds a count aggregate over the reverse FK relation
- Query runs in projection mode and returns dictionaries via `fetch_dicts()`
- `LEFT JOIN` semantics keep rows that have no related records

## with_count() Multi-Segment Paths

`with_count()` also supports nested relationship paths and distinct counts.

```python
# --8<-- [start:with-count-multi-segment]
from sqliter import SqliterDB
from sqliter.orm import BaseDBModel, ManyToMany

class TagCountDemo(BaseDBModel):
name: str

class ArticleCountDemo(BaseDBModel):
title: str
tags: ManyToMany[TagCountDemo] = ManyToMany(
TagCountDemo, related_name="articles"
)

db = SqliterDB(memory=True)
db.create_table(TagCountDemo)
db.create_table(ArticleCountDemo)

python = db.insert(TagCountDemo(name="python"))
sqlite = db.insert(TagCountDemo(name="sqlite"))
db.insert(TagCountDemo(name="unused"))

guide = db.insert(ArticleCountDemo(title="Guide"))
tips = db.insert(ArticleCountDemo(title="Tips"))
db.insert(ArticleCountDemo(title="No Tags"))

guide.tags.add(python, sqlite)
tips.tags.add(python)

rows = (
db.select(TagCountDemo)
.with_count("articles__tags", alias="tag_links")
.with_count("articles__tags", alias="unique_tags", distinct=True)
.order("name")
.fetch_dicts()
)

print("Tags counted across articles__tags:")
for row in rows:
print(
f' {row["name"]}: raw={row["tag_links"]}, '
f'distinct={row["unique_tags"]}'
)

print("\nMulti-segment paths support nested relationship counts.")

db.close()
# --8<-- [end:with-count-multi-segment]
```

### What Happens

- Path `articles__tags` traverses two relationship segments before counting
- Raw count (`distinct=False`) reflects joined row multiplicity
- Distinct count (`distinct=True`) deduplicates terminal related rows

## Relationship Filter Traversal

Filter records by fields on related models using double underscore syntax.
Expand Down
Loading