Bi-Directional Relationship Implementation¶
Last Updated: 2025-12-14 Status: Implemented - Tier 1 (Automatic Backend Sync)
Executive Summary¶
Empirical testing confirms that Stash's backend automatically maintains bidirectional referential integrity for ALL relationship types. No dual mutation coordination is required. This document details the findings and implementation approach.
Empirical Verification Results¶
All tests passed (8/8) confirming automatic bidirectional synchronization:
| Test | Relationship | Pattern | Result |
|---|---|---|---|
| Gallery → Scene (forward) | Many-to-Many | Direct field | ✅ Auto-sync |
| Scene → Gallery (reverse) | Many-to-Many | Direct field | ✅ Auto-sync |
| Tag Parent → Child | Self-referential | Direct field | ✅ Auto-sync |
| Scene → Performer | Many-to-Many | Direct field | ✅ Auto-sync |
| Scene → Studio | Many-to-One | Filter query | ✅ Auto-sync |
| Group Hierarchy | Complex metadata | Nested objects | ✅ Auto-sync |
| Edge Case: Removal | Deletion propagation | N/A | ✅ Auto-sync |
| Edge Case: Bulk Update | Multiple relationships | N/A | ✅ Auto-sync |
Test Details¶
Test Script: verify_bidirectional_relationships.py
Verification Date: 2025-12-14
Stash Version: Latest (as of test date)
Each test: 1. Created entities with relationships 2. Updated one side of the relationship 3. Verified the inverse side automatically updated 4. Cleaned up all test data
Relationship Query Patterns¶
Pattern A: Direct Nested Fields¶
Used by: Gallery, Performer, Tag, Group
Reading:
Writing:
mutation {
sceneUpdate(input: {
id: "456"
gallery_ids: ["123"] # Updates scene → gallery link
}) {
id
}
}
# Backend automatically updates gallery.scenes
Python Example:
# Reading
scene = await client.find_scene("456")
galleries = scene.galleries # [Gallery, Gallery, ...]
# Writing - both directions sync automatically
scene.galleries.append(new_gallery)
await scene.save(client)
# new_gallery.scenes now includes this scene (no second save needed!)
Pattern B: Filter-Based Queries¶
Used by: Studio (and potentially others)
Why this pattern?
- More flexible (supports pagination, sorting, complex filters)
- Studio doesn't have a direct scenes field
- Instead, query scenes BY studio using filters
Reading:
query {
findScenes(
scene_filter: {
studios: {
value: ["studio_id"]
modifier: INCLUDES
}
}
) {
count
scenes {
id
title
}
}
}
Writing:
mutation {
sceneUpdate(input: {
id: "456"
studio_id: "studio_id"
}) {
id
}
}
# Backend automatically updates studio.scene_count and filter results
Python Example:
# Reading - use filter query
scenes = await client.find_scenes(
scene_filter={"studios": {"value": [studio.id]}}
)
# Writing - same as Pattern A
scene.studio = new_studio
await scene.save(client)
# Filter queries now return this scene for new_studio
Pattern C: Complex Objects with Metadata¶
Used by: Group hierarchies
Why this pattern? - Relationships have additional metadata (e.g., description) - Not just a simple ID list
Schema:
type GroupDescription {
group: Group! # The related group
description: String # Metadata about this relationship
}
type Group {
sub_groups: [GroupDescription!]!
containing_groups: [GroupDescription!]!
}
Reading:
query {
findGroup(id: "123") {
sub_groups {
group {
id
name
}
description # Why this sub-group exists
}
}
}
Writing:
mutation {
groupUpdate(input: {
id: "123"
sub_groups: [{
group_id: "456"
description: "Season 1"
}]
}) {
id
}
}
# Backend automatically updates group 456's containing_groups
Python Example:
# Reading - nested structure
group = await client.find_group("123")
for sub_group_desc in group.sub_groups:
sub_group = sub_group_desc.group
notes = sub_group_desc.description
# Writing
group.sub_groups = [
GroupDescriptionInput(group_id="456", description="Season 1")
]
await group.save(client)
# Group 456's containing_groups now includes group 123
Implementation Architecture¶
RelationshipMetadata Class¶
from typing import Callable, Literal
class RelationshipMetadata:
"""Metadata describing a bidirectional relationship.
All relationships in Stash auto-sync bidirectionally via backend
referential integrity. This metadata documents how to read/write
each relationship and provides convenience helpers.
"""
# ===== Write Configuration (Mutations) =====
target_field: str
"""Field name in *UpdateInput/*CreateInput (e.g., 'gallery_ids')."""
is_list: bool
"""True for many-to-many, False for many-to-one."""
transform: Callable[[Any], Any] | None = None
"""Optional transform for complex types (e.g., StashID → StashIDInput)."""
# ===== Read Configuration (Queries) =====
query_field: str | None = None
"""Field name when reading (e.g., 'galleries'). Defaults to relationship name."""
inverse_type: type | None = None
"""Type of the related entity (e.g., Gallery)."""
inverse_query_field: str | None = None
"""Field name on inverse type (e.g., 'scenes' on Gallery)."""
# ===== Query Strategy =====
query_strategy: Literal["direct_field", "filter_query", "complex_object"] = "direct_field"
"""How to query the inverse relationship:
- 'direct_field': Use nested field (e.g., gallery.scenes)
- 'filter_query': Use find* with filter (e.g., findScenes(scene_filter))
- 'complex_object': Nested object with metadata (e.g., group.sub_groups[].group)
"""
filter_query_hint: str | None = None
"""For filter_query strategy, example filter usage."""
# ===== Documentation =====
auto_sync: bool = True
"""Backend maintains referential integrity (always True for Stash)."""
notes: str = ""
"""Additional implementation notes or caveats."""
def __post_init__(self):
"""Set defaults after init."""
# Default query_field to target_field if not provided
if self.query_field is None:
object.__setattr__(self, 'query_field', self.target_field.replace('_ids', 's').replace('_id', ''))
Usage in Entity Types¶
Example 1: Scene (Multiple Patterns)¶
class Scene(StashObject):
__type_name__ = "Scene"
__update_input_type__ = SceneUpdateInput
__relationships__ = {
# Pattern A: Direct field (many-to-many)
"galleries": RelationshipMetadata(
target_field="gallery_ids",
is_list=True,
query_field="galleries",
inverse_type="Gallery", # Forward ref to avoid circular import
inverse_query_field="scenes",
query_strategy="direct_field",
notes="Both scene.galleries and gallery.scenes auto-sync"
),
# Pattern A: Direct field (many-to-many)
"performers": RelationshipMetadata(
target_field="performer_ids",
is_list=True,
query_field="performers",
inverse_type="Performer",
inverse_query_field="scenes",
query_strategy="direct_field",
),
# Pattern B: Filter query (many-to-one)
"studio": RelationshipMetadata(
target_field="studio_id",
is_list=False,
query_field="studio",
inverse_type="Studio",
inverse_query_field=None, # No direct field on Studio
query_strategy="filter_query",
filter_query_hint='findScenes(scene_filter={studios: {value: [studio_id]}})',
notes="Studio has scene_count and filter queries, not direct scenes field"
),
# Pattern A: Direct field (many-to-many)
"tags": RelationshipMetadata(
target_field="tag_ids",
is_list=True,
query_field="tags",
inverse_type="Tag",
inverse_query_field=None, # Tag has scene_count only
query_strategy="direct_field",
notes="Tag has scene_count resolver, not scenes list"
),
# Complex: StashID requires transform
"stash_ids": RelationshipMetadata(
target_field="stash_ids",
is_list=True,
transform=lambda s: StashIDInput(endpoint=s.endpoint, stash_id=s.stash_id),
query_field="stash_ids",
notes="Requires transform to StashIDInput"
),
}
Example 2: Group (Complex Objects)¶
class Group(StashObject):
__type_name__ = "Group"
__update_input_type__ = GroupUpdateInput
__relationships__ = {
# Pattern C: Complex objects with metadata
"sub_groups": RelationshipMetadata(
target_field="sub_groups",
is_list=True,
transform=lambda g: GroupDescriptionInput(
group_id=g.group.id if isinstance(g, GroupDescription) else g.id,
description=g.description if isinstance(g, GroupDescription) else None
),
query_field="sub_groups",
inverse_type="Group",
inverse_query_field="containing_groups",
query_strategy="complex_object",
notes="Uses GroupDescription wrapper with nested group + description"
),
"containing_groups": RelationshipMetadata(
target_field="containing_groups",
is_list=True,
transform=lambda g: GroupDescriptionInput(
group_id=g.group.id if isinstance(g, GroupDescription) else g.id,
description=g.description if isinstance(g, GroupDescription) else None
),
query_field="containing_groups",
inverse_type="Group",
inverse_query_field="sub_groups",
query_strategy="complex_object",
),
}
Convenience Helper Methods¶
Direct Field Helpers¶
class Scene(StashObject):
async def add_to_gallery(self, gallery: "Gallery") -> None:
"""Add this scene to a gallery (auto-syncs both sides).
Backend automatically updates gallery.scenes - no second mutation needed.
"""
if gallery not in self.galleries:
self.galleries.append(gallery)
await self.save()
async def remove_from_gallery(self, gallery: "Gallery") -> None:
"""Remove this scene from a gallery (auto-syncs both sides)."""
if gallery in self.galleries:
self.galleries.remove(gallery)
await self.save()
Filter Query Relationships (Studio, Performer, etc.)¶
Some inverse relationships — like "all scenes for a studio" — aren't direct fields on the entity.
Studio has a scene_count resolver field but no scenes list. Querying scenes by studio requires
a filter query via the client or store.
Using the client directly:
# All scenes for a studio (paginated)
result = await client.find_scenes(
filter={"page": 1, "per_page": 40, "sort": "date", "direction": "DESC"},
scene_filter={"studios": {"value": [studio.id], "modifier": "INCLUDES"}},
)
print(f"{result.count} scenes, first page: {result.scenes}")
# Just the scene count (populate the resolver field)
await store.populate(studio, fields=["scene_count"])
print(studio.scene_count)
Streaming over a large set with populated_filter_iter:
# Iterate all unorganized scenes for a studio, populating files as you go
async for scene in store.populated_filter_iter(
Scene,
required_fields=["files__path"],
predicate=lambda s: (
s.studio is not None
and s.studio.id == studio.id
and not s.organized
),
):
print(scene.files[0].path)
Note: Per-entity lazy-loader methods (
studio.get_scenes(),studio.get_scene_count()) are not implemented. Use the client filter queries and store methods shown above instead.
Complex Object Helpers¶
class Group(StashObject):
async def add_sub_group(
self,
sub_group: "Group",
description: str | None = None
) -> None:
"""Add a sub-group to this group (auto-syncs both sides).
Args:
sub_group: The group to add as a sub-group
description: Optional description of this relationship
"""
sub_group_desc = GroupDescriptionInput(
group_id=sub_group.id,
description=description
)
if not any(sg.group.id == sub_group.id for sg in self.sub_groups):
self.sub_groups.append(sub_group_desc)
await self.save()
Migration Notes¶
Before (Old Pattern)¶
__relationships__ = {
"studio": ("studio_id", False, None), # (target_field, is_list, transform)
"performers": ("performer_ids", True, None),
"galleries": ("gallery_ids", True, None),
}
After (New Pattern)¶
__relationships__ = {
"studio": RelationshipMetadata(
target_field="studio_id",
is_list=False,
inverse_type="Studio",
query_strategy="filter_query",
filter_query_hint='findScenes(scene_filter={studios: {value: [studio_id]}})',
),
"performers": RelationshipMetadata(
target_field="performer_ids",
is_list=True,
inverse_type="Performer",
inverse_query_field="scenes",
),
"galleries": RelationshipMetadata(
target_field="gallery_ids",
is_list=True,
inverse_type="Gallery",
inverse_query_field="scenes",
),
}
Migration Complete¶
All entity types have been migrated to RelationshipMetadata. The old tuple pattern (target_field, is_list) is no longer supported and will raise ValueError.
Key Takeaways¶
- ✅ All relationships auto-sync - Backend maintains referential integrity
- ✅ No dual mutations needed - Single update syncs both sides
- ✅ Three query patterns - Direct fields, filters, complex objects
- ✅ Edge cases work - Removals and bulk updates both auto-sync
- ✅ Metadata for documentation - Not for sync coordination
- ✅ Convenience helpers - Simplify common operations
Future Enhancements¶
- Auto-generate relationship metadata from GraphQL schema
- Add type hints for forward references (avoid circular imports)
- Generate convenience helpers automatically
- Add relationship validation (ensure IDs exist)
- Performance optimization for bulk relationship queries
See Also¶
- UNSET & UUID4 Guide - Field management patterns used with relationships
- Usage Examples - Practical examples using relationship helpers
- Quick Reference - Quick lookup for core patterns
- StashEntityStore API - Identity map that powers bidirectional sync
References¶
- Verification Script:
verify_bidirectional_relationships.py - Test Results: See "Empirical Verification Results" above
- Implementation PR: TBD
- Related Issues: TBD