Relationship DSL¶
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¶
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.
"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.
"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:
"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 viafind{Type}(filter=...). Afilter_query_hintis 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 nofilter_query_hint.
# 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
findFileusing a... on VideoFile { scenes }inline fragment (the field is subtype-only andfindFilereturns theBaseFileinterface). - 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 leftUNSET.
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=...)¶
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.
Used when the relationship itself has data attached. The wire format is a list of wrapper objects instead of a list of IDs.
# 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(forhas_many) — derived frominverse_type+ owner type to produce the rightfind_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:
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)thenauthor.save(client)→ sendsgenre_idson the author update.author.publisher = new_pub; author.save(client)→ sendspublisher_id.await store.populate(author, ["reviews"])→ issuesfind_reviews(filter={"author": {"value": [author.id]}})and attaches the results toauthor.reviews.await store.filter_and_populate(Author, filter={...}, fields=["books__book__title"])→ batched filter + nested population across thehas_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: assignments trigger bulk-update calls on the inverse entity.
See Also¶
- Side Mutations — the mechanism that backs "writable"
has_manyfields likeTag.scenes. - Bidirectional Relationships — architectural rationale for how these relationships stay in sync without dual mutations.
- Batched Mutations — how relationship updates batch when multiple entities are saved together.