# DX Improvements Design Spec

## Overview

Seven developer experience improvements for cendry, all independent of each other.

## 1. Filter repr

Add `__repr__` to `FieldFilterResult`, `And`, and `Or` that returns valid, copy-pasteable Python:

```python
>>> City.state.eq("CA")
FieldFilter("state", "==", "CA")

>>> City.state.ne("CA") & City.population.gt(1_000_000)
And(FieldFilter("state", "!=", "CA"), FieldFilter("population", ">", 1000000))

>>> Or(City.state.eq("CA"), City.state.eq("NY"))
Or(FieldFilter("state", "==", "CA"), FieldFilter("state", "==", "NY"))
```

The repr uses `FieldFilter` (the Firestore class name) not `FieldFilterResult` (the internal class) since that's what users import and what works in copy-paste.

## 2. Dunder filter shortcuts

Add `__eq__`, `__ne__`, `__lt__`, `__le__`, `__gt__`, `__ge__` to `FieldDescriptor`:

```python
City.state == "CA"       # equivalent to City.state.eq("CA")
City.state != "CA"       # equivalent to City.state.ne("CA")
City.population > 100    # equivalent to City.population.gt(100)
City.population >= 100   # equivalent to City.population.gte(100)
City.population < 100    # equivalent to City.population.lt(100)
City.population <= 100   # equivalent to City.population.lte(100)
```

- `__eq__` returns `FieldFilterResult` instead of `bool`
- `__hash__` is set to `None` (unhashable, since `__eq__` is overridden)
- For identity comparison, use `is`

## 3. Context manager

`Cendry` and `AsyncCendry` support the context manager protocol:

```python
with Cendry() as ctx:
    city = ctx.get(City, "SF")

async with AsyncCendry() as ctx:
    city = await ctx.get(City, "SF")
```

- `__enter__` returns `self`
- `__exit__` closes the underlying Firestore client
- `__aenter__` returns `self`
- `__aexit__` closes the underlying async Firestore client
- Still usable without `with` — existing pattern works unchanged

## 4. Export FieldDescriptor

Add `FieldDescriptor` to `cendry.__init__` and `__all__` so users can type-hint functions:

```python
from cendry import FieldDescriptor

def filter_by(descriptor: FieldDescriptor, value: str) -> FieldFilterResult:
    return descriptor.eq(value)
```

## 5. Query object

`select()` and `select_group()` return `Query[T]` / `AsyncQuery[T]` instead of bare iterators.

### Query[T] (sync)

```python
query = ctx.select(City, City.state.eq("CA"), limit=10)

# Iterable (streaming)
for city in query:
    print(city.name)

# Add more filters (returns a new Query — immutable)
query = ctx.select(City).filter(City.state == "CA").filter(City.population > 500_000)

# Chainable with convenience methods
cities = ctx.select(City).filter(City.state == "CA").to_list()

# Convenience methods
cities = query.to_list()      # list[T] — fetches all
first = query.first()         # T | None — limit(1)
city = query.one()            # T — raises if not exactly 1
exists = query.exists()       # bool — limit(1)

# Firestore aggregation (no document fetch)
n = query.count()             # int
```

### AsyncQuery[T] (async)

```python
query = ctx.select(City, City.state.eq("CA"))

async for city in query:
    print(city.name)

# Chainable filtering
query = ctx.select(City).filter(City.state == "CA").filter(City.population > 500_000)

cities = await query.to_list()
first = await query.first()
city = await query.one()
exists = await query.exists()
n = await query.count()
```

### filter()

```python
query.filter(*filters) -> Query[T]  # or AsyncQuery[T]
```

- Accepts same filter types as `select()`: `FieldFilter`, `FieldFilterResult`, `And`, `Or`
- Multiple varargs are implicitly AND'd
- A `list` of filters is also accepted and interpreted as implicit AND:
  ```python
  # These are equivalent:
  query.filter(City.state == "CA", City.population > 500_000)
  query.filter([City.state == "CA", City.population > 500_000])
  ```
- Returns a **new** query — `Query` is immutable
- Applies the filter by calling `.where()` on the underlying Firestore query

### Semantics

- `Query` objects are reusable and immutable — methods return new queries or results, never mutate
- Each method creates a new stream (`.stream()`) — safe to call multiple methods on the same query
- `one()` raises `DocumentNotFoundError` if no results, `CendryError` if more than one
- `count()` uses Firestore's aggregation API (no document fetch)
- `first()` and `exists()` use `limit(1)` internally for efficiency

### Implementation

New module `src/cendry/query.py` — rename existing `query.py` (which only has `Asc`/`Desc`) or extend it. `Query` and `AsyncQuery` live alongside `Asc`/`Desc`.

`select()` and `select_group()` return `Query`/`AsyncQuery` wrapping the built query from `_build_query()`. The streaming logic moves from the context methods into `Query.__iter__`/`AsyncQuery.__aiter__`.

## 6. from_dict

Standalone function to construct a model from a raw dict:

```python
from cendry import from_dict

city = from_dict(City, {"name": "SF", "state": "CA", ...})
city = from_dict(City, {"name": "SF", "mayor": {"name": "Jane", "since": 2020}}, id="123")
```

- Nested `Map` subclass dicts are auto-converted (same logic as deserialization)
- Optional `id` keyword argument for setting the document ID
- Raises `TypeError` with a clear message if required fields are missing

### Deserialization extraction

The deserialization logic currently in `_BaseCendry._deserialize` and `_BaseCendry._deserialize_map` is extracted into `src/cendry/serialize.py` as standalone functions. Both `_BaseCendry` and `from_dict` call them. `_resolve_map_type` also moves there.

## 7. Better error messages

### Missing collection

```python
# Before:
# TypeError: Model 'City' must specify collection: class City(Model, collection='...')

# After:
# TypeError: class City(Model) requires a collection name.
#   Example: class City(Model, collection="cities")
```

### from_dict missing fields

```python
# from_dict(City, {"name": "SF"})
# TypeError: from_dict(City): missing required fields: state, country, capital, population, regions
```

## Project Structure Changes

| File | Change |
|---|---|
| `src/cendry/model.py` | Add `__repr__` to `FieldFilterResult`, `And`, `Or`. Add dunders to `FieldDescriptor`. Improve error message in `Model.__init_subclass__`. |
| `src/cendry/filters.py` | Add `__repr__` to `And`, `Or` |
| `src/cendry/query.py` | Add `Query[T]`, `AsyncQuery[T]` alongside existing `Asc`/`Desc` |
| `src/cendry/serialize.py` | **New** — `from_dict`, `deserialize`, `deserialize_map`, `resolve_map_type` extracted from context |
| `src/cendry/context.py` | Add `__enter__`/`__exit__`/`__aenter__`/`__aexit__`. Use serialize module. Return `Query`/`AsyncQuery` from `select`/`select_group`. |
| `src/cendry/__init__.py` | Export `FieldDescriptor`, `from_dict`, `Query`, `AsyncQuery` |
| `README.md` | Update with dunder shortcuts, context manager, Query methods, from_dict, register_type |

## Public API Additions

Added to `cendry.__init__` and `__all__`:
- `FieldDescriptor`
- `from_dict`
- `Query`
- `AsyncQuery`
