staudenmeir / laravel-adjacency-list

Recursive Laravel Eloquent relationships with CTEs
MIT License
1.37k stars 111 forks source link

Using `MorphToManyOfDescendants` for graph relationships #112

Open eli-s-r opened 2 years ago

eli-s-r commented 2 years ago

Hi @staudenmeir, thank you so much for creating this amazing package and for releasing the new graphs feature last week, which perfectly aligns with our use-case. I was wondering if there's a way to get the functionality of the MorphtoManyOfDescendants relation that is supported for the tree structure OOTB to work for the graph structure.

Below is an oversimplified example of what I'm trying to accomplish (note also the workaround for soft-deleted pivots, there's probably a better way to handle this for the graph structure than what I'm doing?):

class Tag extends Model {
  use HasGraphRelationships {
    descendants as protected baseDescendants;
  }
  use SoftDeletes;

  public function getPivotTableName(): string {
    return 'tag_edges';
  }

  public function getParentKeyName(): string {
    return 'parent_tag_id';
  }

  public function getChildKeyName(): string {
    return 'tag_id';
  }

  public function descendants(
    bool $withTrashed = false,
  ): Descendants|Tag {
    $query = $this->baseDescendants();

    if (!$withTrashed) {
      $query->whereNull("{$this->getExpressionName()}.pivot_deleted_at");
    }

    return $query;
  }
}
/**
 * @property int $id
 * @property int $tag_id
 * @property string $taggable_type
 * @property int $taggable_id
 */
class TaggablePivot extends Model {
  use AsPivot;
  use SoftDeletes;
}
trait HasTags {
  public function tags(
    bool $withTrashed = false,
  ): MorphToMany|Tag {
    $query = $this
      ->morphToMany(
        related: Tag::class,
        name: 'taggable',
        table: 'taggable_pivot',
        foreignPivotKey: 'taggable_id',
        relatedPivotKey: 'tag_id',
      )
      ->withPivot(/* ... */);

    if (!$withTrashed) {
      $query->wherePivotNull('deleted_at');
    }

    return $query;
  }
}
class User extends Model {
  use HasTags;
}

Everything above works smoothly. What would be amazing is to be able to define a descendantTags relation on HasTags exactly like is possible for the tree structure:

public function descendantTags(
  bool $withTrashed = false,
): MorphToManyOfDescendants|Tag {
  $query = $this
    ->morphToManyOfDescendants(
      // ...
    )
    ->withPivot(/* ... */);

  if (!$withTrashed) {
    // some modified logic here
  }

  return $query;
}

I also tried using your eloquent-has-many-deep package, but I don't think it supports the intermediate Descendants relation (understandably).

Do you know of a way to get something like this to work? Thank you so much!

staudenmeir commented 2 years ago

Hi @eli-s-r, Thanks. I'm working on the *OfDescendants relationships, but I'm not sure they would actually solve your case:

Do you need descendantTags to look like this? User -> morph-to-many -> Tag -> recursive descendants -> Tag

eli-s-r commented 2 years ago

@staudenmeir yes, exactly!

eli-s-r commented 2 years ago

@staudenmeir so would this not be covered by the *OfDescendants relationships you mentioned you're working on?

staudenmeir commented 2 years ago

Unfortunately not, *OfDescendants relationships work the other way around:

Tag -> recursive descendants -> Tag -> morph-to-many -> User

Your case/direction is a whole new type of relationship.

How would you use a descendantTags relationship in your app? There might be workarounds.

eli-s-r commented 2 years ago

@staudenmeir we have a bank of educational activities (and other things) that can be tagged by many things (e.g., topic). The structure of the tags is a graph, so breakfast might be a child of food, but also a child of morning activities. If a user searches for food, it's important that anything tagged with food's descendants is also in the results.

For the activity -> ancestor tags direction, what I'm doing is to use the results of the tags query as input into a second query:

public function _getAncestorTagsQuery(
  bool $taggablePivotsWithTrashed = false,
  bool $ancestorsWithTrashed = false,
  bool $includeSelf = false,
): Builder|Tag {
  $descendantTagIds = $this
    ->tags(withTrashed: $taggablePivotsWithTrashed)
    ->pluck('tags.id');

  $baseRelName = $includeSelf
    ? 'descendantsAndSelf'
    : 'descendants';

  $relName = $ancestorsWithTrashed
    // resolves via `resolveRelationUsing`
    ? Tag::getRelationNameWithTrashed($baseRelName)
    : $baseRelName;

  return Tag::query()
    ->whereHas(
      $relName,
      fn (Builder|Tag $query)
      => $query->whereIntegerInRaw(
        'id',
        $descendantTagIds,
      ),
    );
}

For the tags -> descendants -> activities direction, I'm doing the inverse:

public function _getDescendantsQueryForTaggableModel(
  string $modelClass,
  bool $descendantsWithTrashed = false,
  bool $taggablePivotsWithTrashed = false,
  bool $includeSelf = false,
): Builder|HasTagsInterface {
  $descendantQuery = $includeSelf
    ? $this->descendantsAndSelf(withTrashed: $descendantsWithTrashed)
    : $this->descendants(withTrashed: $descendantsWithTrashed);

  $descendantTagIds = $descendantQuery->pluck('id');

  $relName = $taggablePivotsWithTrashed
    ? $modelClass::getRelationNameWithTrashed('tags')
    : 'tags';

  return $modelClass::query()
    ->whereHas(
      $relName,
      fn (Builder|HasTagsInterface $query)
      => $query->whereIntegerInRaw(
        'tags.id',
        $descendantTagIds,
      ),
    );
}

edit: just realized my original goal on the first snippet above was inverted -- I want the ancestors of tags on a model, not the descendants. I updated the first snippet accordingly

staudenmeir commented 2 years ago

For the tags -> descendants -> activities direction, I'm doing the inverse:

A MorphToManyOfDescendants relationship as I described here would solve your inverse case, right (with Activity instead of User)?

Unfortunately not, *OfDescendants relationships work the other way around: Tag -> recursive descendants -> Tag -> morph-to-many -> User