> ## Documentation Index
> Fetch the complete documentation index at: https://docs.jakan.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Relationship DSL

> Declare entity relationships using belongs_to, habtm, has_many, and has_many_through helper factories.

Every `StashObject` subclass declares its relationships to other entities via a `__relationships__` class attribute. The DSL — four helper factories — produces `RelationshipMetadata` entries that drive save mutations, inverse synchronisation, lazy population, and `filter_and_populate` queries.

## The Four Helpers

```python theme={null}
from stash_graphql_client.types import belongs_to, habtm, has_many, has_many_through
```

### `belongs_to(inverse_type, *, inverse_query_field=...)`

The FK side of a many-to-one. The entity *owns the write* through a `*_id` field on its update input type.

```python theme={null}
"studio": belongs_to("Studio", inverse_query_field="scenes"),
"parent_studio": belongs_to("Studio", inverse_query_field="child_studios"),
```

The client writes `studio_id=<id>` on save. The inverse side (Studio.scenes) is auto-populated by the server after any scene update, so no coordination between sides is needed.

### `habtm(inverse_type, *, inverse_query_field=..., transform=...)`

Many-to-many written as a **list of IDs**. The entity owns the write through a `*_ids` field on its update input.

```python theme={null}
"tags": habtm("Tag", inverse_query_field="scenes"),
"performers": habtm("Performer", inverse_query_field="scenes"),
"parents": habtm("Tag", inverse_query_field="children"),  # self-referential
```

The client writes `tag_ids=[...]`, `performer_ids=[...]`, and so on. `transform` converts the in-memory object into the exact input shape when the wire format isn't a plain ID list — see `Scene.stash_ids`:

```python theme={null}
"stash_ids": habtm(
    "StashID",
    transform=lambda s: StashIDInput(endpoint=s.endpoint, stash_id=s.stash_id),
),
```

### `has_many(inverse_type, *, inverse_query_field=..., query_strategy="filter_query")`

The **read-only inverse side** of a relationship. No mutation input exists on this entity's update type. `query_strategy` selects how the inverse list is read:

* **`"filter_query"`** (default) — the server exposes no list resolver for this inverse (only a `*_count`, e.g. `Tag.scene_count`), so the client synthesizes one via `find{Type}(filter=...)`. A `filter_query_hint` is auto-derived.
* **`"direct_field"`** — the server resolves the inverse natively as a nested list field (a real GraphQL resolver). The data arrives embedded in the parent's selection; no synthetic find query and no `filter_query_hint`.

```python theme={null}
# filter_query (default): Tag has scene_count only, no scenes resolver
"scenes": has_many("Scene", inverse_query_field="tags"),
"child_studios": has_many("Studio", inverse_query_field="parent_studio"),

# direct_field: server resolves the nested list (e.g. VideoFile.scenes, #6938)
"scenes": has_many("Scene", inverse_query_field="files",
                   query_strategy="direct_field"),
```

To fetch `tag.scenes`, the store calls `find_scenes(scene_filter={"tags": {"value": [tag.id], ...}})`. `populate()` and `filter_and_populate()` use this strategy automatically.

#### Capability-adaptive direct fields (file reverse relationships)

The file reverse relationships — `VideoFile.scenes`, `ImageFile.images`, `GalleryFile.galleries` (stashapp/stash #6938) — are declared `direct_field`, but their resolvers are **introspection-gated** (`ServerCapabilities.has_file_reverse_relationships`; no appSchema bump, since the PR added no migration). `populate()` adapts per server:

* **resolver present** → fetched via `findFile` using a `... on VideoFile { scenes }` inline fragment (the field is subtype-only and `findFile` returns the `BaseFile` interface).
* **resolver absent** (stable v0.30.x) → falls back to a filter query keyed by the file's **path** (`findScenes(scene_filter={path: {value, EQUALS}})`). Path is the join key because it is the identifier downstream consumers reliably hold. If the path can't be resolved, the field is left `UNSET`.

Either way `file.scenes` is populated, so the feature works on stable servers that predate the resolvers.

### `has_many_through(inverse_type, *, transform, inverse_query_field=...)`

<Warning>
  **Not Rails `through: :model`**

  This helper is **not** the Rails `has_many :through:` pattern. It does not name an intermediate model that joins two sides. It means **"through a wrapper input type that carries relationship-level metadata"** — ordering, descriptions, or similar per-edge data.
</Warning>

Used when the relationship *itself* has data attached. The wire format is a list of wrapper objects instead of a list of IDs.

```python theme={null}
# Scene.groups — each relation carries a scene_index (ordering)
"groups": has_many_through(
    "Group",
    transform=lambda sg: SceneGroupInput(
        group_id=sg.group.id, scene_index=sg.scene_index,
    ),
    inverse_query_field="scenes",
),

# Group.sub_groups / containing_groups — each carries a description
"sub_groups": has_many_through(
    "Group",
    transform=lambda g: GroupDescriptionInput(
        group_id=g.group.id, description=g.description,
    ),
    inverse_query_field="containing_groups",
),
```

`transform` is **required** here (unlike `habtm` where it's optional) because the wrapper input must be constructed explicitly. `query_strategy` is `"complex_object"`.

## Which Helper to Use

| Question                                                                            | Helper             |
| ----------------------------------------------------------------------------------- | ------------------ |
| Does this entity's update input take a single `*_id` field?                         | `belongs_to`       |
| Does it take a `*_ids` list field?                                                  | `habtm`            |
| Does it take a list of wrapper objects with metadata?                               | `has_many_through` |
| Is there *no* input field for this relationship here? (written from the other side) | `has_many`         |

## Auto-Derivation

`RelationshipMetadata` has several fields you don't supply — they're resolved in `__init_subclass__` from the dict key and sibling metadata:

* **`query_field`** — the dict key itself (`"studio"` → `query_field="studio"`).

* **`target_field`** — auto-derived by helper:
  * `belongs_to`: key + `"_id"` (e.g. `studio` → `studio_id`)
  * `habtm`: singularize(key) + `"_ids"` (e.g. `tags` → `tag_ids`, `galleries` → `gallery_ids`)
  * `has_many_through`: key as-is (the wrapper list field keeps its plural name)
  * `has_many`: `""` (no mutation input — read-only)

* **`filter_query_hint`** (for `has_many`) — derived from `inverse_type` + owner type to produce the right `find_X_filter={"owner_field": {...}}` shape.

You can override any of these explicitly when the convention doesn't fit, but the idiomatic code relies on the derivation.

## Full Example

A hypothetical `Author` entity demonstrating all four patterns:

```python theme={null}
from stash_graphql_client.types import (
    StashObject, belongs_to, habtm, has_many, has_many_through,
)

class Author(StashObject):
    __type_name__ = "Author"

    # Simple fields
    name: str | None | UnsetType = UNSET
    bio: str | None | UnsetType = UNSET

    # Relationship fields
    publisher: Publisher | None | UnsetType = UNSET      # many-to-one
    genres: list[Genre] | UnsetType = UNSET              # many-to-many (IDs)
    books: list[AuthoredBook] | UnsetType = UNSET        # many-to-many + order
    reviews: list[Review] | UnsetType = UNSET            # inverse (read-only)

    __relationships__ = {
        "publisher": belongs_to(
            "Publisher",
            inverse_query_field="authors",
        ),
        "genres": habtm(
            "Genre",
            inverse_query_field="authors",
        ),
        "books": has_many_through(
            "Book",
            transform=lambda ab: AuthoredBookInput(
                book_id=ab.book.id, author_order=ab.author_order,
            ),
            inverse_query_field="authors",
        ),
        "reviews": has_many(
            "Review",
            inverse_query_field="author",
        ),
    }
```

With this declaration alone, the following all work:

* `author.genres.append(sci_fi)` then `author.save(client)` → sends `genre_ids` on the author update.
* `author.publisher = new_pub; author.save(client)` → sends `publisher_id`.
* `await store.populate(author, ["reviews"])` → issues `find_reviews(filter={"author": {"value": [author.id]}})` and attaches the results to `author.reviews`.
* `await store.filter_and_populate(Author, filter={...}, fields=["books__book__title"])` → batched filter + nested population across the `has_many_through`.

## Interaction with Save and Side Mutations

For most relationship fields, `save()` just translates the relationship list into its `target_field` on the update input. Some fields — content relationships on `Tag`, `cover` on `Gallery` — are declared as `has_many` (read-only) here because the *owning* entity writes them, but the user experience needs to feel writable. Those fields are handled by [side mutations](/sgc/guide/side-mutations): assignments trigger bulk-update calls on the inverse entity.

## See Also

* [Side Mutations](/sgc/guide/side-mutations) — the mechanism that backs "writable" `has_many` fields like `Tag.scenes`.
* [Bidirectional Relationships](/sgc/architecture/bidirectional-relationships) — architectural rationale for how these relationships stay in sync without dual mutations.
* [Batched Mutations](/sgc/guide/batched-mutations) — how relationship updates batch when multiple entities are saved together.
