# DX Improvements v2 Design Spec

## Overview

Seven features to further improve developer experience. All independent of each other.

## 1. Chainable order_by / limit on Query

`Query` and `AsyncQuery` gain `.order_by()` and `.limit()` methods, returning new immutable queries:

```python
cities = (
    ctx.select(City)
    .filter(City.state == "CA")
    .order_by(City.population)          # ascending by default
    .order_by(City.name.desc())         # descending
    .limit(10)
    .to_list()
)
```

- `order_by(*orders)` — accepts `FieldDescriptor` (ascending by default), `Asc`, or `Desc`. Returns new `Query`.
- `limit(n)` — returns new `Query`.
- `FieldDescriptor` gains `.asc()` and `.desc()` methods returning `Asc`/`Desc`.
- `order_by(City.population)` is shorthand for `order_by(Asc(City.population))`.
- Multiple `.order_by()` calls append — they don't replace previous orderings.
- Alias resolution happens in `FieldDescriptor.asc()`/`.desc()` — the `Asc`/`Desc` objects store the resolved alias string, not the descriptor.
- These complement the existing `order_by=` and `limit=` kwargs on `select()`.

## 2. to_dict standalone

```python
from cendry import to_dict

to_dict(city)                         # {"name": "SF", ...} — Python names
to_dict(city, by_alias=True)          # {"displayName": "SF", ...} — Firestore names
to_dict(city, include_id=True)        # {"name": "SF", ..., "id": "123"}
```

- Standalone function in `serialize.py`, exported from `cendry`.
- Handles nested `Map` subclasses recursively.
- `by_alias=False` by default (Python-facing API).
- `include_id=False` by default (doc ID is metadata).

## 3. get_many

```python
# Sync
cities = ctx.get_many(City, ["SF", "LA", "NYC"])

# Async
cities = await ctx.get_many(City, ["SF", "LA", "NYC"])

# Subcollection
neighborhoods = ctx.get_many(Neighborhood, ["MISSION", "SOMA"], parent=city)
```

- Returns `list[T]` in the same order as input IDs.
- Raises `DocumentNotFoundError` if any IDs are missing — error message includes all missing IDs.
- `async def get_many(...)` on `AsyncCendry` returns `list[T]`.
- Uses Firestore's `get_all()` for batch fetch (single round trip).
- `parent=` parameter for subcollections.

## 4. Field aliases

```python
class City(Model, collection="cities"):
    name: Field[str] = field(alias="displayName")
    state: Field[str]  # no alias — "state" in Firestore
```

### Behavior

- **Filters:** `City.name == "SF"` generates `FieldFilter("displayName", "==", "SF")` using the alias.
- **Ordering:** `Asc(City.name)` / `City.name.asc()` uses `"displayName"` in the Firestore query.
- **Deserialization** (from `get`/`select`): reads `"displayName"` from Firestore, maps to `city.name`.
- **`deserialize()`** (internal, used by `get`/`select`): always reads by alias from the Firestore dict, since Firestore stores the aliased name.
- **`from_dict` / `to_dict`:** `by_alias=False` by default (uses Python names). `by_alias=True` uses Firestore names.

### Implementation

- `field(alias="...")` stores the alias in dataclass field metadata.
- `FieldDescriptor` gains an `alias` property (defaults to `field_name` if no alias).
- `FieldDescriptor` uses `alias` when generating `FieldFilterResult`, `Asc`, `Desc`.
- `_MapMeta` reads alias from field metadata and passes to `FieldDescriptor(field_name, alias=..., owner=cls)`.
- `serialize.py` functions read alias from field metadata for key mapping.
- No alias = field name used as-is (backwards compatible).

## 5. Enum support

```python
import enum

class Status(enum.Enum):
    ACTIVE = "active"
    INACTIVE = "inactive"

class User(Model, collection="users"):
    name: Field[str]
    status: Field[Status]                          # by value (default)
    role: Field[Role] = field(enum_by="name")      # by name
```

- `TypeRegistry` automatically accepts all `enum.Enum` subclasses (including `IntEnum`, `StrEnum`) — no registration needed.
- `field(enum_by="value")` is the default — `Status.ACTIVE` stores `"active"`.
- `field(enum_by="name")` — `Status.ACTIVE` stores `"ACTIVE"`.
- Metadata is stored on the field for future conversion spec. Validation only for now.

## 6. Query repr (copy-pasteable)

All `__repr__` methods return valid Python expressions that can be copy-pasted.

### FieldFilterResult repr

Uses dunder form when possible, method form otherwise:

```python
>>> City.state == "CA"
City.state == 'CA'

>>> City.state != "CA"
City.state != 'CA'

>>> City.population > 100
City.population > 100

>>> City.regions.array_contains("west")
City.regions.array_contains('west')

>>> City.country.is_in(["USA", "Japan"])
City.country.is_in(['USA', 'Japan'])
```

### Asc / Desc repr

```python
>>> Asc(City.population)
City.population.asc()

>>> Desc(City.name)
City.name.desc()
```

### And / Or repr

Uses the filter reprs:

```python
>>> (City.state == "CA") & (City.population > 100)
And(City.state == 'CA', City.population > 100)
```

### Query repr

```python
>>> ctx.select(City)
Query(City)

>>> ctx.select(City, City.state == "CA")
Query(City, City.state == 'CA')

>>> ctx.select(City).filter(City.state == "CA").order_by(City.population).limit(10)
Query(City, City.state == 'CA', order_by=[City.population.asc()], limit=10)
```

`AsyncQuery` shows `AsyncQuery(...)`.

### Owner tracking

- `FieldDescriptor` gains an `owner` attribute — the model class it belongs to. Set by the metaclass.
- `FieldFilterResult`, `Asc`, `Desc` carry `owner` and `field_name` for repr.
- Inheritance: each subclass gets its own `FieldDescriptor` instances with `owner` set to the concrete class, not the mixin/parent it was defined on.
- `FieldDescriptor.__repr__` returns `ClassName.field_name`.

## 7. Pagination helper

```python
# Sync
for page in ctx.select(City, City.state == "CA").paginate(page_size=10):
    print(f"Page with {len(page)} items")
    for city in page:
        print(city.name)

# Async
async for page in ctx.select(City).paginate(page_size=10):
    for city in page:
        print(city.name)
```

- `Query.paginate(page_size)` returns `Iterator[list[T]]`.
- `AsyncQuery.paginate(page_size)` returns `AsyncIterator[list[T]]`.
- Internally: fetches `page_size` docs via `limit()`, uses last doc as `start_after` cursor.
- Stops when a page returns fewer than `page_size` items.
- Each page is a plain `list[T]`.

## Project Structure Changes

| File | Change |
|---|---|
| `src/cendry/model.py` | `FieldDescriptor` gains `owner`, `alias`, `.asc()`, `.desc()`. `FieldFilterResult` gains owner/field for repr. Update `field()` to accept `alias` and `enum_by`. |
| `src/cendry/filters.py` | Update `And`/`Or` repr to use filter reprs. |
| `src/cendry/query.py` | `Query`/`AsyncQuery` gain `.order_by()`, `.limit()`, `.paginate()`, `__repr__`. `Asc`/`Desc` gain `__repr__`, accept `FieldDescriptor`. |
| `src/cendry/serialize.py` | Add `to_dict`. Update `from_dict`, `deserialize` for alias support. |
| `src/cendry/context.py` | Add `get_many` / async `get_many`. |
| `src/cendry/types.py` | Add `enum.Enum` to registry validation. |
| `src/cendry/__init__.py` | Export `to_dict`. |
| `CLAUDE.local.md` | Add copy-pasteable repr and minimize-imports design decisions. |
| `README.md` | Update with new features. |

## Public API Additions

- `to_dict` — added to `cendry.__init__` and `__all__`
- `Asc`/`Desc` remain exported but `.asc()`/`.desc()` on descriptors is preferred

## Design Decisions (for CLAUDE.local.md)

- **Copy-pasteable repr** — all `__repr__` must return valid Python that can be pasted back.
- **Minimize imports** — prefer methods on existing objects over standalone classes. `City.population.asc()` over `Asc(City.population)`. `City.state == "CA"` over `FieldFilter("state", "==", "CA")`.
