# Cendry — Firestore ODM Design Spec

## Overview

Cendry is a Python library that provides an Object-Document Mapper (ODM) on top of `google-cloud-firestore`. It offers typed, dataclass-based models with sync and async support via AnyIO.

**Scope:** Read-only operations for v1. Write operations (save, create, delete) deferred to a future version.

**Target:** Python >= 3.13

## Models

### Model (top-level documents)

`Model` represents a Firestore document bound to a collection. It uses `__init_subclass__` to capture the collection name and automatically applies dataclass behavior.

```python
from cendry import Model, Field, field

class City(Model, collection="cities"):
    name: Field[str]
    state: Field[str]
    country: Field[str]
    capital: Field[bool]
    population: Field[int]
    regions: Field[list[str]]
    nickname: Field[str | None] = field(default=None)
```

- Every `Model` inherits an `id: str | None` field (the Firestore document ID).
- `collection` is required — omitting it raises an error at class definition time.
- `Field[T]` is a descriptor with dual behavior: on class access (`City.state`) it returns a filterable descriptor supporting `.eq()`, `.gt()`, etc.; on instance access (`city.state`) it returns the value typed as `T` by mypy/ty.
- `field()` configures defaults and other field metadata.
- A `Model` **cannot** be nested inside another `Model` via `Field[SomeModel]` — this raises an error at class definition time.

### Map (embedded data)

`Map` represents a Firestore map (nested object within a document). It has no collection, no `id`.

```python
from cendry import Map, Field

class Mayor(Map):
    name: Field[str]
    since: Field[int]

class City(Model, collection="cities"):
    name: Field[str]
    mayor: Field[Mayor]
```

- `Map` can nest other `Map`s.
- `Map` cannot nest `Model`s — error at class definition time.
- `Map` has no `id` field.

## Filters

Two filter APIs coexist:

### FieldFilter (Firestore-native)

`FieldFilter` is Firestore's own class, re-exported by Cendry for convenience.

```python
from cendry import FieldFilter

FieldFilter("state", "==", "CA")
FieldFilter("regions", "array-contains", "west_coast")
FieldFilter("country", "in", ["USA", "Japan"])
```

**Supported operators:** `<`, `<=`, `==`, `>`, `>=`, `!=`, `array-contains`, `array-contains-any`, `in`, `not-in`

### Field Descriptor Methods (typed)

`Field` descriptors expose filter methods that return filter objects:

```python
City.state.eq("CA")
City.state.ne("CA")
City.population.gt(1000000)
City.population.gte(1000000)
City.population.lt(500000)
City.population.lte(500000)
City.regions.array_contains("west_coast")
City.regions.array_contains_any(["west_coast", "east_coast"])
City.country.is_in(["USA", "Japan"])
City.country.not_in(["China"])
```

### Composition

Filters compose via `&` (AND) and `|` (OR):

```python
City.state.ne("CA") & City.population.gt(1000000)
City.state.eq("CA") | (City.country.eq("Japan") & City.population.gt(1000000))
```

Explicit composition is also available:

```python
from cendry import And, Or

Or(
    City.state.eq("CA"),
    And(
        City.country.eq("Japan"),
        City.population.gt(1000000),
    ),
)
```

Multiple filters passed as varargs to `select` are implicitly AND'd:

```python
context.select(City, City.state.eq("CA"), City.population.gt(1000000))
# Equivalent to:
context.select(City, City.state.eq("CA") & City.population.gt(1000000))
```

## Ordering and Pagination

```python
from cendry import Asc, Desc

context.select(City,
    City.state.eq("CA"),
    order_by=[Asc(City.population), Desc(City.name)],
    limit=10,
    start_after={"population": 1000000},
)
```

Named parameters on `select` and `select_group`:

- `order_by: list[Asc | Desc]` — ordering directives
- `limit: int` — max number of results
- `start_at: dict[str, Any] | Model` — cursor: inclusive start (field values dict or a model instance)
- `start_after: dict[str, Any] | Model` — cursor: exclusive start
- `end_at: dict[str, Any] | Model` — cursor: inclusive end
- `end_before: dict[str, Any] | Model` — cursor: exclusive end

## Context Classes

### Cendry (sync)

```python
from cendry import Cendry

# Default credentials
context = Cendry()

# Custom client
from google.cloud.firestore import Client
context = Cendry(client=Client(project="my-project"))
```

### AsyncCendry (async)

```python
from cendry import AsyncCendry

# Default credentials
context = AsyncCendry()

# Custom client
from google.cloud.firestore import AsyncClient
context = AsyncCendry(client=AsyncClient(project="my-project"))
```

Uses AnyIO — works with both asyncio and trio.

### Methods

**`get(model_class, document_id, *, parent=None) -> T`**

Returns the document or raises `DocumentNotFound`.

```python
city = context.get(City, "SF")
city = await async_context.get(City, "SF")
```

**`find(model_class, document_id, *, parent=None) -> T | None`**

Returns the document or `None`.

```python
city = context.find(City, "SF")
city = await async_context.find(City, "SF")
```

**`select(model_class, *filters, order_by=None, limit=None, start_at=None, start_after=None, end_at=None, end_before=None, parent=None) -> Iterator[T] / AsyncIterator[T]`**

Returns a lazy iterator over matching documents.

```python
for city in context.select(City, City.state.eq("CA"), limit=10):
    print(city.name)

async for city in async_context.select(City, City.state.eq("CA")):
    print(city.name)
```

**`select_group(model_class, *filters, order_by=None, limit=None, start_at=None, start_after=None, end_at=None, end_before=None) -> Iterator[T] / AsyncIterator[T]`**

Queries across all subcollections with the given collection name (Firestore collection group query).

```python
for n in context.select_group(Neighborhood, Neighborhood.population.gt(50000)):
    print(n.name)
```

### Subcollections

Subcollections are handled at query time via the `parent` parameter — no relationship declared on models. The `parent` parameter accepts a `Model` instance (which must have a non-`None` `id`).

```python
city = context.get(City, "SF")
for n in context.select(Neighborhood, parent=city):
    print(n.name)
```

Note: `select_group` intentionally has no `parent` parameter — collection group queries are inherently cross-parent.

## Exceptions

- `CendryError` — base exception for all library errors
- `DocumentNotFound(CendryError)` — raised by `get()` when the document does not exist

## Public API Exports

Everything is exported from `cendry`:

```python
from cendry import (
    # Core
    Model, Map, Field, field,
    # Context
    Cendry, AsyncCendry,
    # Filters
    FieldFilter, And, Or,
    # Ordering
    Asc, Desc,
    # Exceptions
    CendryError, DocumentNotFound,
)
```

## Project Structure

```
cendry/
├── src/
│   └── cendry/
│       ├── __init__.py        # Public API exports
│       ├── model.py           # Model, Map, Field, field
│       ├── filters.py         # FieldFilter, And, Or, field descriptor methods
│       ├── query.py           # Asc, Desc, query building internals
│       ├── context.py         # Cendry, AsyncCendry
│       └── exceptions.py      # CendryError, DocumentNotFound
├── tests/
│   ├── features/
│   │   ├── model.feature
│   │   ├── filters.feature
│   │   ├── query.feature
│   │   └── context.feature
│   ├── test_model.py
│   ├── test_filters.py
│   ├── test_query.py
│   └── test_context.py
├── pyproject.toml
└── README.md
```

## Dependencies

**Runtime:**
- `google-cloud-firestore`
- `anyio`

**Dev/Test:**
- `pytest`
- `pytest-bdd`
- `anyio[pytest]`
- `mypy`
- `ty`
- `coverage`

## Tooling

- **Package manager:** uv
- **Python:** >= 3.13 (`requires-python = ">=3.13"`)
- **Type checking:** mypy + ty (strict mode)
- **Testing:** pytest + pytest-bdd, full coverage target
- **Async testing:** anyio pytest plugin (supports asyncio + trio)
