etfz's avatar
Level 1

Relationship property undefined in accessor

Hi,

I have the following:

Controller:

	public function show(Source $source) {
		$source->load([
			'items.image',
		]);

		return view('source.show', [ 'source' => $source ]);
	}

Model

	public function items() {
		return $this->hasMany(Item::class);
	}

	public function getDependenciesAttribute() {
		$itemFileNames = $this->items->whereNotNull('dependency_filename')->pluck('dependency_filename');

		if ($itemFileNames->isEmpty()) {
			return collect();
		}

		return Source
			::where(fn($query) =>
				$query->whereHas('models', fn ($query) => $query->whereIn('filename', $itemFileNames))
					->orWhereHas('items', fn ($query) => $query->whereIn('filename', $itemFileNames))
			)
			->where('id', 'IS DISTINCT FROM', $this->patches_source_id)
			->where('id', 'IS DISTINCT FROM', MAIN_SOURCE_ID)
			->orderBy('title')
			->get();
	}

This works as it should. It's sort of a complex query, but that's beside the point. Now, I wanted to rewrite it to return an Attribute instead, in order to make use of caching:

	public function dependencies(): Attribute {
		$itemFileNames = $this->items->whereNotNull('dependency_filename')->pluck('dependency_filename');

		if ($itemFileNames->isEmpty()) {
			return collect();
		}

		return Attribute::make(
			get: fn () => Source
				::where(fn($query) =>
					$query->whereHas('models', fn ($query) => $query->whereIn('filename', $itemFileNames))
						->orWhereHas('items', fn ($query) => $query->whereIn('filename', $itemFileNames))
				)
				->where('id', 'IS DISTINCT FROM', $this->patches_source_id)
				->where('id', 'IS DISTINCT FROM', MAIN_SOURCE_ID)
				->orderBy('title')
				->get()
		);
	}

but that gives me Undefined property: App\Models\Source::$items on this line:

$itemFileNames = $this->items->whereNotNull('dependency_filename')->pluck('dependency_filename');

I don't understand why this is happening, and indeed if I try to dump $this->items just before this line it dumps a Collection of Items. Dumping the exact same expression also works as expected.

0 likes
6 replies
tisuchi's avatar

@etfz how about this approach?

$itemFileNames = $this->getRelationValue('items')->whereNotNull('dependency_filename')->pluck('dependency_filename');
etfz's avatar
Level 1

@tisuchi getRelationValue seems to work the same as using attributes, but doesn't really answer my question.

I have been able to find a partial explanation, however. Regardless of which method I use to access the items relationship, I noticed I am now getting the following error on other pages that loads sources (but don't use the dependencies accessor), referring to the first line of my attribute method:

Attempted to lazy load [items] on model [App\Models\Source] but lazy loading is disabled.

I guess this means the attribute method (dependences()) is always called when a source is loaded, but the actual attribute getter is not called until needed.

In the end this works, and does not perform that subquery twice like I thought it would, but it's not pretty.

	public function dependencies(): Attribute {
		return Attribute::make(
			get: fn () => Source
				::where(fn($query) =>
					$query->whereHas('models', fn ($query) => $query->whereIn('filename', $this->getRelationValue('items')->whereNotNull('dependency_filename')->pluck('dependency_filename')))
						->orWhereHas('items', fn ($query) => $query->whereIn('filename', $this->getRelationValue('items')->whereNotNull('dependency_filename')->pluck('dependency_filename')))
				)
				->where('id', 'IS DISTINCT FROM', $this->patches_source_id)
				->where('id', 'IS DISTINCT FROM', MAIN_SOURCE_ID)
				->orderBy('title')
				->get()
		);
	}

Maybe there's a nicer way to write this query:

$query->whereHas('models', fn ($query) => $query->whereIn('filename', $this->getRelationValue('items')->whereNotNull('dependency_filename')->pluck('dependency_filename')))
	->orWhereHas('items', fn ($query) => $query->whereIn('filename', $this->getRelationValue('items')->whereNotNull('dependency_filename')->pluck('dependency_filename')))
etfz's avatar
Level 1

I can still use a normal function, I guess.

		return Attribute::make(
			get: function () {
				$itemFileNames = $this->items->whereNotNull('dependency_filename')->pluck('dependency_filename');
				return Source
					::where(fn($query) =>
						$query->whereHas('models', fn ($query) => $query->whereIn('filename', $itemFileNames))
							->orWhereHas('items', fn ($query) => $query->whereIn('filename', $itemFileNames))
					)
					->where('id', 'IS DISTINCT FROM', $this->patches_source_id)
					->where('id', 'IS DISTINCT FROM', MAIN_SOURCE_ID)
					->orderBy('title')
					->get();
			}
		);
Snapey's avatar

Running database queries from inside models is ALWAYS a code smell.

As I said before, you cannot eager load (or cache) dynamic attributes

etfz's avatar
Level 1

@Snapey I don't know what you mean by not being able to cache dynamic attributes, but this did save me one query (according to the Clockwork browser extension) when doing if items->isNotEmpty() { foreach items }.

What would be an appropriate way to do this, given that I want to reuse it on multiple pages?

Please or to participate in this conversation.