Skip to content

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:

query {
  findGallery(id: "123") {
    scenes {        # Direct field returns [Scene!]!
      id
      title
    }
  }
}

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

  1. All relationships auto-sync - Backend maintains referential integrity
  2. No dual mutations needed - Single update syncs both sides
  3. Three query patterns - Direct fields, filters, complex objects
  4. Edge cases work - Removals and bulk updates both auto-sync
  5. Metadata for documentation - Not for sync coordination
  6. 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

References

  • Verification Script: verify_bidirectional_relationships.py
  • Test Results: See "Empirical Verification Results" above
  • Implementation PR: TBD
  • Related Issues: TBD