Skip to content

Commit 2ec1bdb

Browse files
perf: batch lookup-type resolution to eliminate table-render N+1 [3.x] (#138)
* perf: batch lookup-type resolution to eliminate table-render N+1 LookupResolver previously fired one query per row when rendering SingleChoice/MultiChoice columns with a lookup_type. For a 50-row table that meant 50 queries to the lookup model. Introduces a request-scoped LookupCache that sits behind the resolver and is primed proactively by scopeWithCustomFieldValues via afterQuery. The preloader scans loaded customFieldValues, groups referenced IDs by lookup_type, and fetches all titles in one query per lookup_type. Tables and infolists that already use withCustomFieldValues() get this for free — per-row resolve() calls hit the cache and fire zero queries. Tables that don't call the scope still benefit: repeated IDs across rows or columns collapse to a single query. Tests cover: - LookupCache contract (remember/titleFor/missing/flush + dedup + scoping) - Resolver: second call with same IDs fires zero queries - Resolver: partial cache hit only queries missing IDs - Resolver: non-scalar values skipped without hitting DB - Resolver: titles returned in submission order - Full preload: withCustomFieldValues + per-row resolve = 0 queries to lookup model * fix: bind LookupCache as scoped so queue workers don't leak titles across jobs * refactor: extract LookupAttributeResolver; null-check resource; clearer error - Single source of truth for (lookupInstance, titleAttribute) resolution, used by both LookupResolver and LookupPreloader. - Throws RuntimeException early when Filament::getModelResource returns null instead of silently calling app(null) and crashing in the container. - Rewords MissingRecordTitleAttributeException to mention both the lookup model and the resource so the error is actionable. - Preloader handles Collection/Arrayable values (multi-choice lookups stored in json_value via the AsCollection cast) that were previously skipped. - Preloader conditionals split into single-concern continues to satisfy ChangeOrIfContinueToMultiContinueRector. - scalarIdsFromValue uses in_array(..., true) for the null/''/[] short-circuit to satisfy RepeatedOrEqualToInArrayRector. - LookupResolver filters blank string ids up front so we never fire a whereIn containing an empty string and never cache entries under ''. Review threads: singleton→scoped, Collection handling, null resource, empty strings, duplicated helper, and error message clarity. * test: add regression tests for Collection values and blank strings; use scoped query log
1 parent 7afab6d commit 2ec1bdb

8 files changed

Lines changed: 545 additions & 19 deletions

File tree

src/CustomFieldsServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use Relaticle\CustomFields\Providers\ValidationServiceProvider;
3535
use Relaticle\CustomFields\Services\ModelAttributeDiscoveryService;
3636
use Relaticle\CustomFields\Services\TenantContextService;
37+
use Relaticle\CustomFields\Services\ValueResolver\LookupCache;
3738
use Relaticle\CustomFields\Services\ValueResolver\ValueResolver;
3839
use Relaticle\CustomFields\Services\Visibility\BackendVisibilityService;
3940
use Spatie\LaravelPackageTools\Commands\InstallCommand;
@@ -55,6 +56,7 @@ public function bootingPackage(): void
5556

5657
$this->app->singleton(CustomsFieldsMigrators::class, CustomFieldsMigrator::class);
5758
$this->app->singleton(ValueResolvers::class, ValueResolver::class);
59+
$this->app->scoped(LookupCache::class);
5860

5961
$this->app->singleton(TenantContextService::class);
6062
$this->app->singleton(BackendVisibilityService::class);

src/Models/Concerns/UsesCustomFields.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Filament\Facades\Filament;
88
use Illuminate\Database\Eloquent\Builder;
9+
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
910
use Illuminate\Database\Eloquent\Model;
1011
use Illuminate\Database\Eloquent\Relations\MorphMany;
1112
use Illuminate\Support\Collection;
@@ -17,6 +18,7 @@
1718
use Relaticle\CustomFields\Models\CustomField;
1819
use Relaticle\CustomFields\Models\CustomFieldValue;
1920
use Relaticle\CustomFields\QueryBuilders\CustomFieldQueryBuilder;
21+
use Relaticle\CustomFields\Services\ValueResolver\LookupPreloader;
2022

2123
/**
2224
* @see HasCustomFields
@@ -124,7 +126,13 @@ public function customFieldValues(): MorphMany
124126

125127
public function scopeWithCustomFieldValues(Builder $query): Builder
126128
{
127-
return $query->with('customFieldValues.customField.options');
129+
return $query
130+
->with('customFieldValues.customField.options')
131+
->afterQuery(function ($records): void {
132+
if ($records instanceof EloquentCollection) {
133+
app(LookupPreloader::class)->preload($records);
134+
}
135+
});
128136
}
129137

130138
public function getCustomFieldValue(CustomField $customField): mixed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Relaticle\CustomFields\Services\ValueResolver;
6+
7+
use Filament\Facades\Filament;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\Relation;
10+
use Relaticle\CustomFields\Exceptions\MissingRecordTitleAttributeException;
11+
use RuntimeException;
12+
13+
/**
14+
* Resolves the (lookup model instance, title attribute) pair for a given
15+
* lookup_type, by consulting Filament's registered resource for the model.
16+
*
17+
* Centralized here so LookupResolver and LookupPreloader share exactly one
18+
* source of truth — previously each class had its own copy of this logic.
19+
*/
20+
final readonly class LookupAttributeResolver
21+
{
22+
/**
23+
* @return array{0: Model, 1: string}
24+
*/
25+
public function resolve(string $lookupType): array
26+
{
27+
$lookupModelPath = Relation::getMorphedModel($lookupType) ?? $lookupType;
28+
$lookupInstance = app($lookupModelPath);
29+
30+
$resourcePath = Filament::getModelResource($lookupModelPath);
31+
32+
if ($resourcePath === null) {
33+
throw new RuntimeException(sprintf(
34+
'No Filament resource is registered for lookup model `%s`.',
35+
$lookupModelPath,
36+
));
37+
}
38+
39+
$resourceInstance = app($resourcePath);
40+
$recordTitleAttribute = $resourceInstance->getRecordTitleAttribute();
41+
42+
throw_if(
43+
$recordTitleAttribute === null,
44+
new MissingRecordTitleAttributeException(sprintf(
45+
'The lookup model `%s` does not have a configured record title attribute on resource `%s`.',
46+
$lookupModelPath,
47+
$resourcePath,
48+
))
49+
);
50+
51+
return [$lookupInstance, $recordTitleAttribute];
52+
}
53+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Relaticle\CustomFields\Services\ValueResolver;
6+
7+
/**
8+
* Request-scoped cache of resolved lookup titles keyed by (lookup_type, id).
9+
*
10+
* Populated either lazily by LookupResolver on demand, or eagerly by the
11+
* scopeWithCustomFieldValues afterQuery hook. Either way, downstream column
12+
* and infolist renders hit the cache instead of firing one query per row.
13+
*/
14+
final class LookupCache
15+
{
16+
/** @var array<string, array<int|string, string>> */
17+
private array $titles = [];
18+
19+
public function titleFor(string $lookupType, int|string $id): ?string
20+
{
21+
return $this->titles[$lookupType][$id] ?? null;
22+
}
23+
24+
/**
25+
* @param array<int|string, string> $map id => title
26+
*/
27+
public function remember(string $lookupType, array $map): void
28+
{
29+
$this->titles[$lookupType] = ($this->titles[$lookupType] ?? []) + $map;
30+
}
31+
32+
/**
33+
* Return the subset of IDs that have not been cached yet.
34+
*
35+
* @param array<int, int|string> $ids
36+
* @return array<int, int|string>
37+
*/
38+
public function missing(string $lookupType, array $ids): array
39+
{
40+
$cached = $this->titles[$lookupType] ?? [];
41+
42+
return array_values(array_filter(
43+
array_unique($ids),
44+
static fn (int|string $id): bool => ! array_key_exists($id, $cached),
45+
));
46+
}
47+
48+
public function flush(): void
49+
{
50+
$this->titles = [];
51+
}
52+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Relaticle\CustomFields\Services\ValueResolver;
6+
7+
use Illuminate\Contracts\Support\Arrayable;
8+
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
9+
use Illuminate\Database\Eloquent\Model;
10+
use Relaticle\CustomFields\Models\Contracts\HasCustomFields;
11+
use Relaticle\CustomFields\Models\CustomField;
12+
use Relaticle\CustomFields\Models\CustomFieldValue;
13+
14+
/**
15+
* Scans a set of loaded host records for their custom-field lookup references
16+
* and primes the LookupCache with one query per lookup_type.
17+
*
18+
* Called by scopeWithCustomFieldValues's afterQuery hook so tables and
19+
* infolists get batched lookup resolution for free.
20+
*/
21+
final readonly class LookupPreloader
22+
{
23+
public function __construct(
24+
private LookupCache $cache,
25+
private LookupAttributeResolver $attributes,
26+
) {}
27+
28+
/**
29+
* @param EloquentCollection<int, Model> $records
30+
*/
31+
public function preload(EloquentCollection $records): void
32+
{
33+
if ($records->isEmpty()) {
34+
return;
35+
}
36+
37+
$idsByLookupType = [];
38+
39+
foreach ($records as $record) {
40+
if (! $record instanceof HasCustomFields) {
41+
continue;
42+
}
43+
44+
if (! $record->relationLoaded('customFieldValues')) {
45+
continue;
46+
}
47+
48+
/** @var iterable<CustomFieldValue> $values */
49+
$values = $record->getRelation('customFieldValues');
50+
51+
foreach ($values as $value) {
52+
$field = $value->customField;
53+
54+
if (! $field instanceof CustomField) {
55+
continue;
56+
}
57+
58+
if ($field->lookup_type === null) {
59+
continue;
60+
}
61+
62+
foreach ($this->scalarIdsFromValue($value->getValue()) as $id) {
63+
$idsByLookupType[$field->lookup_type][] = $id;
64+
}
65+
}
66+
}
67+
68+
foreach ($idsByLookupType as $lookupType => $ids) {
69+
$missing = $this->cache->missing($lookupType, $ids);
70+
71+
if ($missing === []) {
72+
continue;
73+
}
74+
75+
[$lookupInstance, $recordTitleAttribute] = $this->attributes->resolve($lookupType);
76+
77+
$titles = $lookupInstance->newQuery()
78+
->whereIn('id', $missing)
79+
->pluck($recordTitleAttribute, 'id')
80+
->map(static fn (mixed $title): string => (string) $title)
81+
->all();
82+
83+
$this->cache->remember($lookupType, $titles);
84+
}
85+
}
86+
87+
/**
88+
* @return array<int, int|string>
89+
*/
90+
private function scalarIdsFromValue(mixed $value): array
91+
{
92+
if (in_array($value, [null, '', []], true)) {
93+
return [];
94+
}
95+
96+
if ($value instanceof Arrayable) {
97+
$value = $value->toArray();
98+
}
99+
100+
$candidates = is_array($value) ? $value : [$value];
101+
102+
return array_values(array_filter(
103+
$candidates,
104+
static fn (mixed $id): bool => is_int($id) || (is_string($id) && $id !== ''),
105+
));
106+
}
107+
}

src/Services/ValueResolver/LookupResolver.php

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@
44

55
namespace Relaticle\CustomFields\Services\ValueResolver;
66

7-
use Filament\Facades\Filament;
8-
use Illuminate\Database\Eloquent\Relations\Relation;
97
use Illuminate\Support\Collection;
10-
use Relaticle\CustomFields\Exceptions\MissingRecordTitleAttributeException;
118
use Relaticle\CustomFields\FieldTypeSystem\FieldManager;
129
use Relaticle\CustomFields\Models\CustomField;
1310
use Throwable;
1411

1512
final readonly class LookupResolver
1613
{
14+
public function __construct(
15+
private LookupCache $cache,
16+
private LookupAttributeResolver $attributes,
17+
) {}
18+
1719
/**
1820
* Resolve lookup values based on the custom field configuration.
1921
*
@@ -36,30 +38,39 @@ public function resolveLookupValues(array $values, CustomField $customField): Co
3638
return $customField->options->whereIn('id', $values)->pluck('name');
3739
}
3840

39-
[$lookupInstance, $recordTitleAttribute] = $this->getLookupAttributes($customField->lookup_type);
40-
41-
return $lookupInstance->whereIn('id', $values)->pluck($recordTitleAttribute);
41+
return $this->resolveAgainstLookupModel($customField->lookup_type, $values);
4242
}
4343

4444
/**
45-
* @return array{0: mixed, 1: string}
45+
* @param array<int, mixed> $values
46+
* @return Collection<int, string>
4647
*
4748
* @throws Throwable
4849
*/
49-
private function getLookupAttributes(string $lookupType): array
50+
private function resolveAgainstLookupModel(string $lookupType, array $values): Collection
5051
{
51-
$lookupModelPath = Relation::getMorphedModel($lookupType) ?? $lookupType;
52-
$lookupInstance = app($lookupModelPath);
52+
$scalarIds = array_values(array_filter(
53+
$values,
54+
static fn (mixed $id): bool => is_int($id) || (is_string($id) && $id !== ''),
55+
));
5356

54-
$resourcePath = Filament::getModelResource($lookupModelPath);
55-
$resourceInstance = app($resourcePath);
56-
$recordTitleAttribute = $resourceInstance->getRecordTitleAttribute();
57+
$missing = $this->cache->missing($lookupType, $scalarIds);
5758

58-
throw_if(
59-
$recordTitleAttribute === null,
60-
new MissingRecordTitleAttributeException(sprintf('The `%s` does not have a record title custom attribute.', $resourcePath))
61-
);
59+
if ($missing !== []) {
60+
[$lookupInstance, $recordTitleAttribute] = $this->attributes->resolve($lookupType);
61+
62+
$freshTitles = $lookupInstance->newQuery()
63+
->whereIn('id', $missing)
64+
->pluck($recordTitleAttribute, 'id')
65+
->map(static fn (mixed $title): string => (string) $title)
66+
->all();
67+
68+
$this->cache->remember($lookupType, $freshTitles);
69+
}
6270

63-
return [$lookupInstance, $recordTitleAttribute];
71+
return collect($scalarIds)
72+
->map(fn (int|string $id): ?string => $this->cache->titleFor($lookupType, $id))
73+
->reject(static fn (?string $title): bool => $title === null)
74+
->values();
6475
}
6576
}

0 commit comments

Comments
 (0)