Skip to content

popoto.fields.relationship

popoto.fields.relationship

Relationship field for creating foreign-key-like references between Popoto models.

This module implements the relationship system that allows Popoto models to reference other models, similar to Django's ForeignKey. The design philosophy centers on using Redis Sets to maintain bidirectional indexes, enabling efficient queries in both directions (e.g., "find all Memberships for a Person" or "find the Person for a Membership").

Key Design Decisions
  1. Set-Based Indexing: Rather than storing just the foreign key value, Popoto maintains Redis Sets that index the relationship. When you save a Membership with person=alice, Popoto adds the Membership's db_key to a Set keyed by Alice's db_key. This enables O(1) lookups of "all objects pointing to Alice".

  2. Lazy Loading: Related model instances are stored as db_key strings and loaded on demand, avoiding expensive eager loading of entire object graphs.

  3. Django-Style Query Syntax: Supports double-underscore traversal for filtering through relationships (e.g., Membership.query.filter(person__name="Alice")).

  4. Explicit Model Declaration: Unlike Django's string-based lazy references, Popoto requires the related model class to be passed directly. This simplifies the implementation but requires careful import ordering.

Example

class Person(Model): name = KeyField()

class Membership(Model): person = Relationship(model=Person) role = Field(type=str)

alice = Person.create(name="Alice") m = Membership.create(person=alice, role="admin")

Query through relationship

memberships = Membership.query.filter(person=alice) memberships = Membership.query.filter(person__name="Alice")

Relationship

Bases: Field

A field that stores references to other model instances, analogous to a ForeignKey.

The first positional argument is the related Model class. Internally the relationship is stored as the related instance's redis_key string, which is lazily loaded back into a full model instance on access. This prevents infinite recursion with circular references.

The Relationship field enables models to reference other models while maintaining queryable indexes. When a model instance with a Relationship is saved, Popoto automatically maintains a Redis Set that tracks which instances point to each related object.

This bidirectional indexing is the key innovation: traditional key-value stores only allow lookup by key, but Popoto's relationship Sets enable efficient reverse lookups ("find all X that reference Y") without scanning all records.

A field value can be one of three types at runtime:

  • Model instance - fully loaded relationship.
  • str - a redis_key (lazy-loaded, not yet resolved).
  • None - no relationship set.

Attributes:

Name Type Description
model Model

The related Model class that this field references. Must be a concrete Model subclass, not a string reference.

many bool

Reserved for future many-to-many support. Currently not fully implemented.

null bool

Whether the relationship can be None. Defaults to True, allowing optional relationships.

Redis Data Structure

For each (Model, field_name, related_instance) combination, Popoto maintains a Set at key: $RelationshipF:ModelClass:field_name:related_db_key This Set contains the db_keys of all instances that reference the related object.

Example

class Author(Model): name = KeyField()

class Book(Model): title = KeyField() author = Relationship(model=Author)

tolkien = Author.create(name="Tolkien") Book.create(title="The Hobbit", author=tolkien) Book.create(title="LOTR", author=tolkien)

Efficient lookup: "all books by Tolkien"

books = Book.query.filter(author=tolkien) # Returns both books

Note

The type attribute is set to Model (the base class), not the specific related model. This is because type checking happens at the Field base class level and needs to accept any Model subclass.

Source code in src/popoto/fields/relationship.py
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
class Relationship(Field):
    """
    A field that stores references to other model instances, analogous to a ForeignKey.

    The first positional argument is the related Model class. Internally the
    relationship is stored as the related instance's ``redis_key`` string,
    which is lazily loaded back into a full model instance on access. This
    prevents infinite recursion with circular references.

    The Relationship field enables models to reference other models while maintaining
    queryable indexes. When a model instance with a Relationship is saved, Popoto
    automatically maintains a Redis Set that tracks which instances point to each
    related object.

    This bidirectional indexing is the key innovation: traditional key-value stores
    only allow lookup by key, but Popoto's relationship Sets enable efficient
    reverse lookups ("find all X that reference Y") without scanning all records.

    A field value can be one of three types at runtime:

    * ``Model`` instance - fully loaded relationship.
    * ``str`` - a redis_key (lazy-loaded, not yet resolved).
    * ``None`` - no relationship set.

    Attributes:
        model: The related Model class that this field references. Must be a concrete
            Model subclass, not a string reference.
        many: Reserved for future many-to-many support. Currently not fully implemented.
        null: Whether the relationship can be None. Defaults to True, allowing
            optional relationships.

    Redis Data Structure:
        For each (Model, field_name, related_instance) combination, Popoto maintains
        a Set at key: `$RelationshipF:ModelClass:field_name:related_db_key`
        This Set contains the db_keys of all instances that reference the related object.

    Example:
        class Author(Model):
            name = KeyField()

        class Book(Model):
            title = KeyField()
            author = Relationship(model=Author)

        tolkien = Author.create(name="Tolkien")
        Book.create(title="The Hobbit", author=tolkien)
        Book.create(title="LOTR", author=tolkien)

        # Efficient lookup: "all books by Tolkien"
        books = Book.query.filter(author=tolkien)  # Returns both books

    Note:
        The `type` attribute is set to Model (the base class), not the specific
        related model. This is because type checking happens at the Field base
        class level and needs to accept any Model subclass.
    """

    type: "Model" = None
    model: "Model" = None
    many: bool = False
    null: bool = True

    def __init__(self, **kwargs):
        """
        Initialize a Relationship field with the specified related model.

        The initialization defers the Model import to avoid circular dependencies,
        since both Model and Relationship need to reference each other. This is
        a common pattern in ORM design where fields and models are tightly coupled.

        Args:
            **kwargs: Field configuration options including:
                - model: The Model class this relationship points to (required for queries)
                - many: Boolean for many-to-many relationships (future feature)
                - null: Whether None is allowed (default True)
        """
        super().__init__(**kwargs)
        from ..models.base import Model

        relationship_field_defaults = {
            "type": Model,
            "model": None,
            "many": False,
            "null": True,
        }
        self.field_defaults.update(relationship_field_defaults)
        # set field options, let kwargs override
        for k, v in relationship_field_defaults.items():
            setattr(self, k, kwargs.get(k, v))

    @classmethod
    def is_valid(cls, field, value, null_check=True, **kwargs) -> bool:
        """
        Validate a Relationship field value.

        Accepts three value types as valid:
        - A Model instance (the related model)
        - A str containing a redis_key reference (lazy-loaded, format "ClassName:key")
        - None (if field.null is True)

        This override is necessary because the base Field.is_valid() only checks
        isinstance(value, field.type), which rejects the str redis_key values
        that are set during lazy loading from Redis.
        """
        if not null_check and value is None:
            return True
        if field.null and value is None:
            return True
        if value is None:
            logger.error(f"Relationship field {field} is null but null=False")
            return False
        # Accept redis_key strings (lazy-loaded relationship references)
        if isinstance(value, str):
            if ":" not in value:
                logger.error(
                    f"Relationship field {field} has invalid redis_key string: {value}"
                )
                return False
            return True
        # Accept Model instances
        from ..models.base import Model

        if isinstance(value, Model):
            return True
        logger.error(
            f"Relationship field {field} expected Model instance or redis_key string, "
            f"got {type(value)}"
        )
        return False

    def get_filter_query_params(self, field_name) -> set:
        """
        Build the set of valid query parameters for filtering on this relationship.

        This method enables Django-style double-underscore query syntax by traversing
        into the related model and collecting its filterable fields. For example, if
        a Book has an `author` Relationship to Author, and Author has a `name` field,
        this method makes `author__name` a valid filter parameter.

        The implementation deliberately avoids recursive relationship traversal to
        prevent infinite loops (e.g., Person -> Friend -> Person) and to keep query
        compilation tractable.

        Args:
            field_name: The name of this Relationship field on the parent model.

        Returns:
            A set of valid query parameter strings, including:

                - The field name itself (for direct model instance filtering)
                - Chained parameters like `field_name__related_field` for each
                  filterable field on the related model
        """
        related_field_filter_query_params = set()
        for related_field_name, related_field in self.model._meta.fields.items():
            if isinstance(related_field, Relationship):
                continue  # not ready for recursive compilation
            for related_query_param in related_field.get_filter_query_params(
                related_field_name
            ):
                related_field_filter_query_params.add(
                    f"{field_name}__{related_query_param}"
                )

        return (
            super()
            .get_filter_query_params(field_name)
            .union(
                {
                    f"{field_name}",
                }
            )
            .union(related_field_filter_query_params)
        )

    @classmethod
    def on_save(
        cls,
        model_instance: "Model",
        field_name: str,
        field_value: "Model | str | None",
        pipeline=None,
        **kwargs,
    ):
        """
        Maintain the relationship index Set when a model instance is saved.

        This hook is called automatically during model save operations. It updates
        the Redis Set that indexes which instances point to each related object.
        This is the core mechanism that enables efficient reverse lookups.

        When the relationship value changes (e.g., player.team = team_b), the
        instance is removed from the old related object's index set before being
        added to the new one. This prevents ghost entries that would cause stale
        query results. The old value is detected via ``_saved_field_values``,
        which stores field values at the last save/load.

        The index key follows the pattern:
            `$RelationshipF:ModelClass:field_name:related_db_key`

        For example, saving a Membership with person=alice would add the
        Membership's db_key to the Set at `$RelationshipF:Membership:person:Person:Alice`.

        Args:
            model_instance: The model instance being saved.
            field_name: The name of the Relationship field.
            field_value: The related Model instance (or None).
            pipeline: Optional Redis pipeline for batched operations.
            **kwargs: Additional arguments (unused, for extensibility).

        Returns:
            The pipeline (for chaining) or None if no pipeline was provided.

        Note:
            When field_value is None, the instance is removed from the index Set.
            This handles the case where a relationship is being cleared.
        """
        from ..models.base import Model

        # Handle different field_value types:
        # - None: relationship was never set or being cleared
        # - Model instance: fully loaded relationship
        # - str: lazy-loaded relationship (redis_key string due to circular reference protection)
        if field_value is None:
            related_db_key = "None"
        elif isinstance(field_value, Model):
            related_db_key = field_value.db_key
        elif isinstance(field_value, str):
            # field_value is the redis_key string (lazy-loaded but never accessed)
            # Expecting format "ClassName:key_value"
            if ":" not in field_value:
                logger.error(
                    f"Invalid redis_key format for {field_name}: {field_value}. Expected 'ClassName:key_value'"
                )
                return pipeline if pipeline else None
            # Parse the redis_key string into DB_key components
            related_db_key = DB_key.from_redis_key(field_value)
        else:
            # Unknown type, log and return without action
            logger.warning(
                f"Unexpected field_value type in on_save: {type(field_value)} for {field_name}"
            )
            return pipeline if pipeline else None

        # Remove from old index if the Relationship value changed.
        # _saved_field_values tracks values at last save/load.  If the current
        # value differs from the saved value, the old index Set still contains
        # this instance's key -- remove it to prevent ghost entries in queries.
        # This mirrors the proven pattern from KeyFieldMixin.on_save() (PR #150).
        saved_values = getattr(model_instance, "_saved_field_values", {})
        old_value = saved_values.get(field_name)
        if old_value is not None and old_value != field_value:
            # Resolve old value to a related_db_key using the same type-dispatch
            if isinstance(old_value, Model):
                old_related_db_key = old_value.db_key
            elif isinstance(old_value, str):
                if ":" in old_value:
                    old_related_db_key = DB_key.from_redis_key(old_value)
                else:
                    old_related_db_key = None
            else:
                old_related_db_key = None

            if old_related_db_key is not None:
                old_relationship_set_db_key = DB_key(
                    cls.get_special_use_field_db_key(model_instance, field_name),
                    old_related_db_key,
                )
                member_key = model_instance.db_key.redis_key
                if pipeline:
                    pipeline.srem(old_relationship_set_db_key.redis_key, member_key)
                else:
                    POPOTO_REDIS_DB.srem(
                        old_relationship_set_db_key.redis_key, member_key
                    )

        # on a one-to-many, save the set of many with the related instance
        # add this instance's id to a relationship set based on the related model
        # example: "$RelationshipF:Membership:person:person_db_key"
        relationship_set_db_key = DB_key(
            cls.get_special_use_field_db_key(model_instance, field_name),
            related_db_key,
        )

        if field_value is None:
            if pipeline:
                return pipeline.srem(
                    relationship_set_db_key.redis_key, model_instance.db_key.redis_key
                )
            else:
                return POPOTO_REDIS_DB.srem(
                    relationship_set_db_key.redis_key, model_instance.db_key.redis_key
                )
        else:
            if pipeline:
                return pipeline.sadd(
                    relationship_set_db_key.redis_key, model_instance.db_key.redis_key
                )
            else:
                return POPOTO_REDIS_DB.sadd(
                    relationship_set_db_key.redis_key, model_instance.db_key.redis_key
                )

    @classmethod
    def on_delete(
        cls,
        model_instance: "Model",
        field_name: str,
        field_value: "Model | str | None",
        pipeline: redis.client.Pipeline = None,
        **kwargs,
    ):
        """
        Clean up the relationship index Set when a model instance is deleted.

        This hook ensures referential integrity by removing the deleted instance
        from the relationship index. Without this cleanup, queries would return
        stale references to deleted objects.

        Args:
            model_instance: The model instance being deleted.
            field_name: The name of the Relationship field.
            field_value: The related Model instance (or None).
            pipeline: Optional Redis pipeline for batched operations.
            **kwargs: Additional arguments (unused, for extensibility).

        Returns:
            The pipeline (for chaining) or the result of SREM if no pipeline.

        Warning:
            There is a known edge case where lazy-loaded relationships may not
            be fully hydrated at delete time. If the relationship was accessed
            through a chain (e.g., person.friend.friend), the field_value may
            be a key string rather than a Model instance, causing this method
            to fail when accessing field_value.db_key.
        """
        # todo: it's possible this instance is not fully loaded or has been changed.
        #  Need to reload from db before deleting
        #  Example: on person.friend.delete() the person.friend.friend will have field_value as a keystring
        #  It will not be a model instance, so this method will fail on field_value.db_key below
        from ..models.base import Model

        # Handle different field_value types:
        # - None: relationship was never set
        # - Model instance: fully loaded relationship
        # - str: lazy-loaded relationship (redis_key string due to circular reference protection)
        if field_value is None:
            related_db_key = "None"
        elif isinstance(field_value, Model):
            related_db_key = field_value.db_key
        elif isinstance(field_value, str):
            # field_value is the redis_key string (lazy-loaded but never accessed)
            # Expecting format "ClassName:key_value"
            if ":" not in field_value:
                logger.error(
                    f"Invalid redis_key format for {field_name}: {field_value}. Expected 'ClassName:key_value'"
                )
                return pipeline if pipeline else None
            # Parse the redis_key string into DB_key components
            related_db_key = DB_key.from_redis_key(field_value)
        else:
            # Unknown type, log and return without action
            logger.warning(
                f"Unexpected field_value type in on_delete: {type(field_value)} for {field_name}"
            )
            return pipeline if pipeline else None

        relationship_set_db_key = DB_key(
            cls.get_special_use_field_db_key(model_instance, field_name),
            related_db_key,
        )
        # Use saved_redis_key if provided, otherwise fall back to current db_key
        member_key = kwargs.get("saved_redis_key", model_instance.db_key.redis_key)
        if pipeline:
            return pipeline.srem(relationship_set_db_key.redis_key, member_key)
        else:
            return POPOTO_REDIS_DB.srem(relationship_set_db_key.redis_key, member_key)

    @classmethod
    def filter_query(cls, model: "Model", field_name: str, **query_params) -> set:
        """
        Execute a filter query on a Relationship field, returning matching db_keys.

        This method handles two types of queries:

        1. **Direct Instance Filtering** (`field_name=instance`):
           Looks up the relationship index Set directly. For example,
           `Membership.query.filter(person=alice)` retrieves the Set at
           `$RelationshipF:Membership:person:Person:Alice`.

        2. **Chained Field Filtering** (`field_name__related_field=value`):
           First queries the related model to find matching instances, then
           looks up each of their relationship index Sets. For example,
           `Membership.query.filter(person__name="Alice")` first finds all
           Persons named Alice, then retrieves all Memberships pointing to them.

        The chained approach supports recursive traversal through multiple
        relationships, though this can become expensive for deep chains.

        Args:
            model: The Model class being queried.
            field_name: The name of the Relationship field.
            **query_params: Filter parameters (e.g., person=alice or person__name="Alice").

        Returns:
            A set of db_key bytes for matching model instances. Returns an empty
            set if no matches are found.

        Raises:
            QueryException: If filtering directly on the field with a non-Model value.

        Performance Note:
            Direct instance filtering is O(1) as it's a single Set lookup.
            Chained filtering requires querying the related model first, then
            performing N Set lookups where N is the number of matching related
            instances. Results are intersected if multiple filters are applied.
        """
        from ..models.base import Model

        keys_lists_to_intersect = list()
        pipeline = POPOTO_REDIS_DB.pipeline()

        for query_param, query_value in query_params.items():
            if query_param == f"{field_name}":
                if not isinstance(query_value, Model):
                    raise QueryException(
                        f"Query filter on Relationship expects model instance. Instead, got {query_value}"
                    )

                relationship_set_db_key = DB_key(
                    cls.get_special_use_field_db_key(model, field_name),
                    query_value.db_key,
                )
                keys_lists_to_intersect.append(
                    POPOTO_REDIS_DB.smembers(relationship_set_db_key.redis_key)
                )

            elif query_param.startswith(f"{field_name}__"):
                field = model._meta.fields[field_name]
                relationship_field_name = query_param.strip(f"{field_name}__").split(
                    "__"
                )[0]

                relationship_field_values = field.model._meta.fields[
                    relationship_field_name
                ].filter_query(
                    model=field.model,
                    field_name=relationship_field_name,
                    **{query_param.strip(f"{field_name}__"): query_value},
                )
                # note: this will be recursive if references another relationship and so forth

                for relationship_field_value in relationship_field_values:
                    relationship_set_db_key = DB_key(
                        cls.get_special_use_field_db_key(model, field_name),
                        relationship_field_value.db_key,
                    )
                    pipeline.smembers(relationship_set_db_key.redis_key)

        keys_lists_to_intersect += pipeline.execute()
        logger.debug(keys_lists_to_intersect)
        if len(keys_lists_to_intersect):
            return set.intersection(
                *[set(key_list) for key_list in keys_lists_to_intersect]
            )
        return set()

is_valid(field, value, null_check=True, **kwargs) classmethod

Validate a Relationship field value.

Accepts three value types as valid: - A Model instance (the related model) - A str containing a redis_key reference (lazy-loaded, format "ClassName:key") - None (if field.null is True)

This override is necessary because the base Field.is_valid() only checks isinstance(value, field.type), which rejects the str redis_key values that are set during lazy loading from Redis.

Source code in src/popoto/fields/relationship.py
@classmethod
def is_valid(cls, field, value, null_check=True, **kwargs) -> bool:
    """
    Validate a Relationship field value.

    Accepts three value types as valid:
    - A Model instance (the related model)
    - A str containing a redis_key reference (lazy-loaded, format "ClassName:key")
    - None (if field.null is True)

    This override is necessary because the base Field.is_valid() only checks
    isinstance(value, field.type), which rejects the str redis_key values
    that are set during lazy loading from Redis.
    """
    if not null_check and value is None:
        return True
    if field.null and value is None:
        return True
    if value is None:
        logger.error(f"Relationship field {field} is null but null=False")
        return False
    # Accept redis_key strings (lazy-loaded relationship references)
    if isinstance(value, str):
        if ":" not in value:
            logger.error(
                f"Relationship field {field} has invalid redis_key string: {value}"
            )
            return False
        return True
    # Accept Model instances
    from ..models.base import Model

    if isinstance(value, Model):
        return True
    logger.error(
        f"Relationship field {field} expected Model instance or redis_key string, "
        f"got {type(value)}"
    )
    return False

get_filter_query_params(field_name)

Build the set of valid query parameters for filtering on this relationship.

This method enables Django-style double-underscore query syntax by traversing into the related model and collecting its filterable fields. For example, if a Book has an author Relationship to Author, and Author has a name field, this method makes author__name a valid filter parameter.

The implementation deliberately avoids recursive relationship traversal to prevent infinite loops (e.g., Person -> Friend -> Person) and to keep query compilation tractable.

Parameters:

Name Type Description Default
field_name

The name of this Relationship field on the parent model.

required

Returns:

Type Description
set

A set of valid query parameter strings, including:

  • The field name itself (for direct model instance filtering)
  • Chained parameters like field_name__related_field for each filterable field on the related model
Source code in src/popoto/fields/relationship.py
def get_filter_query_params(self, field_name) -> set:
    """
    Build the set of valid query parameters for filtering on this relationship.

    This method enables Django-style double-underscore query syntax by traversing
    into the related model and collecting its filterable fields. For example, if
    a Book has an `author` Relationship to Author, and Author has a `name` field,
    this method makes `author__name` a valid filter parameter.

    The implementation deliberately avoids recursive relationship traversal to
    prevent infinite loops (e.g., Person -> Friend -> Person) and to keep query
    compilation tractable.

    Args:
        field_name: The name of this Relationship field on the parent model.

    Returns:
        A set of valid query parameter strings, including:

            - The field name itself (for direct model instance filtering)
            - Chained parameters like `field_name__related_field` for each
              filterable field on the related model
    """
    related_field_filter_query_params = set()
    for related_field_name, related_field in self.model._meta.fields.items():
        if isinstance(related_field, Relationship):
            continue  # not ready for recursive compilation
        for related_query_param in related_field.get_filter_query_params(
            related_field_name
        ):
            related_field_filter_query_params.add(
                f"{field_name}__{related_query_param}"
            )

    return (
        super()
        .get_filter_query_params(field_name)
        .union(
            {
                f"{field_name}",
            }
        )
        .union(related_field_filter_query_params)
    )

on_save(model_instance, field_name, field_value, pipeline=None, **kwargs) classmethod

Maintain the relationship index Set when a model instance is saved.

This hook is called automatically during model save operations. It updates the Redis Set that indexes which instances point to each related object. This is the core mechanism that enables efficient reverse lookups.

When the relationship value changes (e.g., player.team = team_b), the instance is removed from the old related object's index set before being added to the new one. This prevents ghost entries that would cause stale query results. The old value is detected via _saved_field_values, which stores field values at the last save/load.

The index key follows the pattern

$RelationshipF:ModelClass:field_name:related_db_key

For example, saving a Membership with person=alice would add the Membership's db_key to the Set at $RelationshipF:Membership:person:Person:Alice.

Parameters:

Name Type Description Default
model_instance Model

The model instance being saved.

required
field_name str

The name of the Relationship field.

required
field_value Model | str | None

The related Model instance (or None).

required
pipeline

Optional Redis pipeline for batched operations.

None
**kwargs

Additional arguments (unused, for extensibility).

{}

Returns:

Type Description

The pipeline (for chaining) or None if no pipeline was provided.

Note

When field_value is None, the instance is removed from the index Set. This handles the case where a relationship is being cleared.

Source code in src/popoto/fields/relationship.py
@classmethod
def on_save(
    cls,
    model_instance: "Model",
    field_name: str,
    field_value: "Model | str | None",
    pipeline=None,
    **kwargs,
):
    """
    Maintain the relationship index Set when a model instance is saved.

    This hook is called automatically during model save operations. It updates
    the Redis Set that indexes which instances point to each related object.
    This is the core mechanism that enables efficient reverse lookups.

    When the relationship value changes (e.g., player.team = team_b), the
    instance is removed from the old related object's index set before being
    added to the new one. This prevents ghost entries that would cause stale
    query results. The old value is detected via ``_saved_field_values``,
    which stores field values at the last save/load.

    The index key follows the pattern:
        `$RelationshipF:ModelClass:field_name:related_db_key`

    For example, saving a Membership with person=alice would add the
    Membership's db_key to the Set at `$RelationshipF:Membership:person:Person:Alice`.

    Args:
        model_instance: The model instance being saved.
        field_name: The name of the Relationship field.
        field_value: The related Model instance (or None).
        pipeline: Optional Redis pipeline for batched operations.
        **kwargs: Additional arguments (unused, for extensibility).

    Returns:
        The pipeline (for chaining) or None if no pipeline was provided.

    Note:
        When field_value is None, the instance is removed from the index Set.
        This handles the case where a relationship is being cleared.
    """
    from ..models.base import Model

    # Handle different field_value types:
    # - None: relationship was never set or being cleared
    # - Model instance: fully loaded relationship
    # - str: lazy-loaded relationship (redis_key string due to circular reference protection)
    if field_value is None:
        related_db_key = "None"
    elif isinstance(field_value, Model):
        related_db_key = field_value.db_key
    elif isinstance(field_value, str):
        # field_value is the redis_key string (lazy-loaded but never accessed)
        # Expecting format "ClassName:key_value"
        if ":" not in field_value:
            logger.error(
                f"Invalid redis_key format for {field_name}: {field_value}. Expected 'ClassName:key_value'"
            )
            return pipeline if pipeline else None
        # Parse the redis_key string into DB_key components
        related_db_key = DB_key.from_redis_key(field_value)
    else:
        # Unknown type, log and return without action
        logger.warning(
            f"Unexpected field_value type in on_save: {type(field_value)} for {field_name}"
        )
        return pipeline if pipeline else None

    # Remove from old index if the Relationship value changed.
    # _saved_field_values tracks values at last save/load.  If the current
    # value differs from the saved value, the old index Set still contains
    # this instance's key -- remove it to prevent ghost entries in queries.
    # This mirrors the proven pattern from KeyFieldMixin.on_save() (PR #150).
    saved_values = getattr(model_instance, "_saved_field_values", {})
    old_value = saved_values.get(field_name)
    if old_value is not None and old_value != field_value:
        # Resolve old value to a related_db_key using the same type-dispatch
        if isinstance(old_value, Model):
            old_related_db_key = old_value.db_key
        elif isinstance(old_value, str):
            if ":" in old_value:
                old_related_db_key = DB_key.from_redis_key(old_value)
            else:
                old_related_db_key = None
        else:
            old_related_db_key = None

        if old_related_db_key is not None:
            old_relationship_set_db_key = DB_key(
                cls.get_special_use_field_db_key(model_instance, field_name),
                old_related_db_key,
            )
            member_key = model_instance.db_key.redis_key
            if pipeline:
                pipeline.srem(old_relationship_set_db_key.redis_key, member_key)
            else:
                POPOTO_REDIS_DB.srem(
                    old_relationship_set_db_key.redis_key, member_key
                )

    # on a one-to-many, save the set of many with the related instance
    # add this instance's id to a relationship set based on the related model
    # example: "$RelationshipF:Membership:person:person_db_key"
    relationship_set_db_key = DB_key(
        cls.get_special_use_field_db_key(model_instance, field_name),
        related_db_key,
    )

    if field_value is None:
        if pipeline:
            return pipeline.srem(
                relationship_set_db_key.redis_key, model_instance.db_key.redis_key
            )
        else:
            return POPOTO_REDIS_DB.srem(
                relationship_set_db_key.redis_key, model_instance.db_key.redis_key
            )
    else:
        if pipeline:
            return pipeline.sadd(
                relationship_set_db_key.redis_key, model_instance.db_key.redis_key
            )
        else:
            return POPOTO_REDIS_DB.sadd(
                relationship_set_db_key.redis_key, model_instance.db_key.redis_key
            )

on_delete(model_instance, field_name, field_value, pipeline=None, **kwargs) classmethod

Clean up the relationship index Set when a model instance is deleted.

This hook ensures referential integrity by removing the deleted instance from the relationship index. Without this cleanup, queries would return stale references to deleted objects.

Parameters:

Name Type Description Default
model_instance Model

The model instance being deleted.

required
field_name str

The name of the Relationship field.

required
field_value Model | str | None

The related Model instance (or None).

required
pipeline Pipeline

Optional Redis pipeline for batched operations.

None
**kwargs

Additional arguments (unused, for extensibility).

{}

Returns:

Type Description

The pipeline (for chaining) or the result of SREM if no pipeline.

Warning

There is a known edge case where lazy-loaded relationships may not be fully hydrated at delete time. If the relationship was accessed through a chain (e.g., person.friend.friend), the field_value may be a key string rather than a Model instance, causing this method to fail when accessing field_value.db_key.

Source code in src/popoto/fields/relationship.py
@classmethod
def on_delete(
    cls,
    model_instance: "Model",
    field_name: str,
    field_value: "Model | str | None",
    pipeline: redis.client.Pipeline = None,
    **kwargs,
):
    """
    Clean up the relationship index Set when a model instance is deleted.

    This hook ensures referential integrity by removing the deleted instance
    from the relationship index. Without this cleanup, queries would return
    stale references to deleted objects.

    Args:
        model_instance: The model instance being deleted.
        field_name: The name of the Relationship field.
        field_value: The related Model instance (or None).
        pipeline: Optional Redis pipeline for batched operations.
        **kwargs: Additional arguments (unused, for extensibility).

    Returns:
        The pipeline (for chaining) or the result of SREM if no pipeline.

    Warning:
        There is a known edge case where lazy-loaded relationships may not
        be fully hydrated at delete time. If the relationship was accessed
        through a chain (e.g., person.friend.friend), the field_value may
        be a key string rather than a Model instance, causing this method
        to fail when accessing field_value.db_key.
    """
    # todo: it's possible this instance is not fully loaded or has been changed.
    #  Need to reload from db before deleting
    #  Example: on person.friend.delete() the person.friend.friend will have field_value as a keystring
    #  It will not be a model instance, so this method will fail on field_value.db_key below
    from ..models.base import Model

    # Handle different field_value types:
    # - None: relationship was never set
    # - Model instance: fully loaded relationship
    # - str: lazy-loaded relationship (redis_key string due to circular reference protection)
    if field_value is None:
        related_db_key = "None"
    elif isinstance(field_value, Model):
        related_db_key = field_value.db_key
    elif isinstance(field_value, str):
        # field_value is the redis_key string (lazy-loaded but never accessed)
        # Expecting format "ClassName:key_value"
        if ":" not in field_value:
            logger.error(
                f"Invalid redis_key format for {field_name}: {field_value}. Expected 'ClassName:key_value'"
            )
            return pipeline if pipeline else None
        # Parse the redis_key string into DB_key components
        related_db_key = DB_key.from_redis_key(field_value)
    else:
        # Unknown type, log and return without action
        logger.warning(
            f"Unexpected field_value type in on_delete: {type(field_value)} for {field_name}"
        )
        return pipeline if pipeline else None

    relationship_set_db_key = DB_key(
        cls.get_special_use_field_db_key(model_instance, field_name),
        related_db_key,
    )
    # Use saved_redis_key if provided, otherwise fall back to current db_key
    member_key = kwargs.get("saved_redis_key", model_instance.db_key.redis_key)
    if pipeline:
        return pipeline.srem(relationship_set_db_key.redis_key, member_key)
    else:
        return POPOTO_REDIS_DB.srem(relationship_set_db_key.redis_key, member_key)

filter_query(model, field_name, **query_params) classmethod

Execute a filter query on a Relationship field, returning matching db_keys.

This method handles two types of queries:

  1. Direct Instance Filtering (field_name=instance): Looks up the relationship index Set directly. For example, Membership.query.filter(person=alice) retrieves the Set at $RelationshipF:Membership:person:Person:Alice.

  2. Chained Field Filtering (field_name__related_field=value): First queries the related model to find matching instances, then looks up each of their relationship index Sets. For example, Membership.query.filter(person__name="Alice") first finds all Persons named Alice, then retrieves all Memberships pointing to them.

The chained approach supports recursive traversal through multiple relationships, though this can become expensive for deep chains.

Parameters:

Name Type Description Default
model Model

The Model class being queried.

required
field_name str

The name of the Relationship field.

required
**query_params

Filter parameters (e.g., person=alice or person__name="Alice").

{}

Returns:

Type Description
set

A set of db_key bytes for matching model instances. Returns an empty

set

set if no matches are found.

Raises:

Type Description
QueryException

If filtering directly on the field with a non-Model value.

Performance Note

Direct instance filtering is O(1) as it's a single Set lookup. Chained filtering requires querying the related model first, then performing N Set lookups where N is the number of matching related instances. Results are intersected if multiple filters are applied.

Source code in src/popoto/fields/relationship.py
@classmethod
def filter_query(cls, model: "Model", field_name: str, **query_params) -> set:
    """
    Execute a filter query on a Relationship field, returning matching db_keys.

    This method handles two types of queries:

    1. **Direct Instance Filtering** (`field_name=instance`):
       Looks up the relationship index Set directly. For example,
       `Membership.query.filter(person=alice)` retrieves the Set at
       `$RelationshipF:Membership:person:Person:Alice`.

    2. **Chained Field Filtering** (`field_name__related_field=value`):
       First queries the related model to find matching instances, then
       looks up each of their relationship index Sets. For example,
       `Membership.query.filter(person__name="Alice")` first finds all
       Persons named Alice, then retrieves all Memberships pointing to them.

    The chained approach supports recursive traversal through multiple
    relationships, though this can become expensive for deep chains.

    Args:
        model: The Model class being queried.
        field_name: The name of the Relationship field.
        **query_params: Filter parameters (e.g., person=alice or person__name="Alice").

    Returns:
        A set of db_key bytes for matching model instances. Returns an empty
        set if no matches are found.

    Raises:
        QueryException: If filtering directly on the field with a non-Model value.

    Performance Note:
        Direct instance filtering is O(1) as it's a single Set lookup.
        Chained filtering requires querying the related model first, then
        performing N Set lookups where N is the number of matching related
        instances. Results are intersected if multiple filters are applied.
    """
    from ..models.base import Model

    keys_lists_to_intersect = list()
    pipeline = POPOTO_REDIS_DB.pipeline()

    for query_param, query_value in query_params.items():
        if query_param == f"{field_name}":
            if not isinstance(query_value, Model):
                raise QueryException(
                    f"Query filter on Relationship expects model instance. Instead, got {query_value}"
                )

            relationship_set_db_key = DB_key(
                cls.get_special_use_field_db_key(model, field_name),
                query_value.db_key,
            )
            keys_lists_to_intersect.append(
                POPOTO_REDIS_DB.smembers(relationship_set_db_key.redis_key)
            )

        elif query_param.startswith(f"{field_name}__"):
            field = model._meta.fields[field_name]
            relationship_field_name = query_param.strip(f"{field_name}__").split(
                "__"
            )[0]

            relationship_field_values = field.model._meta.fields[
                relationship_field_name
            ].filter_query(
                model=field.model,
                field_name=relationship_field_name,
                **{query_param.strip(f"{field_name}__"): query_value},
            )
            # note: this will be recursive if references another relationship and so forth

            for relationship_field_value in relationship_field_values:
                relationship_set_db_key = DB_key(
                    cls.get_special_use_field_db_key(model, field_name),
                    relationship_field_value.db_key,
                )
                pipeline.smembers(relationship_set_db_key.redis_key)

    keys_lists_to_intersect += pipeline.execute()
    logger.debug(keys_lists_to_intersect)
    if len(keys_lists_to_intersect):
        return set.intersection(
            *[set(key_list) for key_list in keys_lists_to_intersect]
        )
    return set()