Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Store ratings average reviews by subject_uuid. Without an index every
* rating lookup scanned the whole reviews table.
*/
public function up(): void
{
if ($this->indexExists('reviews', 'reviews_subject_uuid_index')) {
return;
}

Schema::connection(config('storefront.connection.db'))->table('reviews', function (Blueprint $table) {
$table->index('subject_uuid');
});
}

public function down(): void
{
if (!$this->indexExists('reviews', 'reviews_subject_uuid_index')) {
return;
}

Schema::connection(config('storefront.connection.db'))->table('reviews', function (Blueprint $table) {
$table->dropIndex(['subject_uuid']);
});
}

protected function indexExists(string $table, string $index): bool
{
try {
$indexes = Schema::connection(config('storefront.connection.db'))
->getConnection()
->getDoctrineSchemaManager()
->listTableIndexes($table);

return isset($indexes[$index]);
} catch (Throwable $e) {
return false;
}
}
};
14 changes: 14 additions & 0 deletions server/src/Http/Controllers/StoreController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Fleetbase\Storefront\Http\Controllers;

use Fleetbase\Storefront\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;

class StoreController extends StorefrontController
Expand All @@ -14,6 +15,19 @@ class StoreController extends StorefrontController
*/
public $resource = 'store';

/**
* Eager-load what the store resource always serializes, so listing stores
* does not run logo, backdrop and rating queries for every store.
*/
public function onQueryRecord(Builder $builder, Request $request)
{
$builder->with(['logo', 'backdrop'])->withAvg('reviews', 'rating');

if ($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category'))) {
$builder->with('networks');
}
}

public function allStores(Request $request)
{
$stores = Store::select(['uuid', 'name', 'description', 'created_at'])
Expand Down
29 changes: 26 additions & 3 deletions server/src/Http/Controllers/v1/NetworkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@

class NetworkController extends Controller
{
/**
* The point to measure store distances from, or null when the request has no usable location.
*
* The coordinate parser falls back to (0, 0) for missing or invalid input. Treating that as a
* location made every store's distance be measured from the Gulf of Guinea, filtered out every
* store when maximum_distance was set, and loaded the whole network for in-memory sorting.
*/
protected static function resolveReferencePoint($location): ?Point
{
if (blank($location)) {
return null;
}

$point = Utils::getPointFromCoordinates($location);

if ((float) $point->getLat() === 0.0 && (float) $point->getLng() === 0.0) {
return null;
}

return $point;
}

/**
* Returns all stores within the network.
*
Expand All @@ -34,7 +56,7 @@ public function stores(Request $request)
$location = $request->input('location');
$maxDistance = $request->input('maximum_distance', null);
$exclude = $request->input('exclude', []);
$coordinates = Utils::getPointFromCoordinates($location);
$coordinates = static::resolveReferencePoint($location);
$requiresDistancePostProcessing = $coordinates instanceof Point && ($sort === 'nearest' || is_numeric($maxDistance));

if (is_string($tagged)) {
Expand All @@ -52,6 +74,7 @@ public function stores(Request $request)
/** @var \Illuminate\Database\Query\Builder $query */
$query = Store::select('*')
->with(['logo', 'backdrop', 'media', 'locations.place'])
->withAvg('reviews', 'rating')
->whereHas('locations')
->whereHas('networks', function ($q) use ($request) {
$q->where('network_uuid', session('storefront_network'));
Expand Down Expand Up @@ -114,10 +137,10 @@ public function stores(Request $request)

switch ($sort) {
case 'highest_rated':
$query->withAvg('reviews', 'rating')->orderByDesc('reviews_avg_rating');
$query->orderByDesc('reviews_avg_rating');
break;
case 'lowest_rated':
$query->withAvg('reviews', 'rating')->orderBy('reviews_avg_rating');
$query->orderBy('reviews_avg_rating');
break;
case 'newest':
$query->orderByDesc('created_at');
Expand Down
9 changes: 5 additions & 4 deletions server/src/Http/Resources/Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ public function toArray($request)
'alertable' => $this->alertable,
'is_network' => false,
'is_store' => true,
'category' => $this->when($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category')), new Category($this->getNetworkCategoryUsingId($request->input('network')))),
'networks' => $this->when($request->boolean('with_networks') || $request->inArray('with', 'networks'), Network::collection($this->networks)),
'locations' => $this->when($request->boolean('with_locations') || $request->inArray('with', 'locations'), $this->locations->mapInto(StoreLocation::class)),
'media' => $this->when($request->boolean('with_media') || $request->inArray('with', 'media'), Media::collection($this->media)),
// Closures, so relations and lookups only run when the field is requested.
'category' => $this->when($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category')), fn () => new Category($this->getNetworkCategoryUsingId($request->input('network')))),
'networks' => $this->when($request->boolean('with_networks') || $request->inArray('with', 'networks'), fn () => Network::collection($this->networks)),
'locations' => $this->when($request->boolean('with_locations') || $request->inArray('with', 'locations'), fn () => $this->locations->mapInto(StoreLocation::class)),
'media' => $this->when($request->boolean('with_media') || $request->inArray('with', 'media'), fn () => Media::collection($this->media)),
'slug' => $this->slug,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
Expand Down
41 changes: 36 additions & 5 deletions server/src/Models/Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,14 @@ public function getBackdropUrlAttribute()
*/
public function getRatingAttribute()
{
// Use the average preloaded with withAvg('reviews', 'rating') when present, so
// listing stores does not run one aggregate query per store.
if (array_key_exists('reviews_avg_rating', $this->attributes)) {
$average = $this->attributes['reviews_avg_rating'];

return is_numeric($average) ? $average + 0 : 0;
}

return $this->reviews()->avg('rating') ?? 0;
}

Expand All @@ -356,13 +364,33 @@ public function getNetworkCategoryUsingId(?string $id)
return null;
}

$network = Network::where('uuid', $id)->orWhere('public_id', $id)->first();
// A network's uuid never changes, so resolve each id once instead of once per store.
if (!array_key_exists($id, static::$networkUuidsById)) {
static::$networkUuidsById[$id] = Network::where('uuid', $id)->orWhere('public_id', $id)->value('uuid');
}

if (!$network instanceof Network) {
$networkUuid = static::$networkUuidsById[$id];

if (!$networkUuid) {
return null;
}

return $this->getNetworkCategory($network);
return $this->getNetworkCategory((new Network())->forceFill(['uuid' => $networkUuid]));
}

/**
* Network uuids resolved by uuid or public id.
*
* @var array<string, string|null>
*/
protected static array $networkUuidsById = [];

/**
* Forget network ids resolved by getNetworkCategoryUsingId().
*/
public static function flushResolvedNetworkIds(): void
{
static::$networkUuidsById = [];
}

/**
Expand All @@ -374,8 +402,11 @@ public function getNetworkCategoryUsingId(?string $id)
*/
public function getNetworkCategory(Network $network)
{
// Find the relationship between this store and the given network
$networkRelation = $this->networks()->where('networks.uuid', $network->uuid)->first();
// Find the relationship between this store and the given network, using the
// eager-loaded networks when available.
$networkRelation = $this->relationLoaded('networks')
? $this->networks->firstWhere('uuid', $network->uuid)
: $this->networks()->where('networks.uuid', $network->uuid)->first();

// Check if the relationship exists
if ($networkRelation) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -613,3 +613,78 @@ function createNetworkApiControllerSchema(): void
->and($missingLocations->getStatusCode())->toBe(400)
->and($missingTags->getStatusCode())->toBe(400);
});

test('network stores endpoint ignores distance filters without a usable location', function () {
createNetworkApiControllerSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
$connection->table('networks')->insert(['uuid' => 'network_uuid']);
$connection->table('stores')->insert([
['uuid' => 'store_one_uuid', 'public_id' => 'store_one', 'company_uuid' => 'company_uuid', 'name' => 'One', 'created_at' => '2026-01-01 00:00:00'],
['uuid' => 'store_two_uuid', 'public_id' => 'store_two', 'company_uuid' => 'company_uuid', 'name' => 'Two', 'created_at' => '2026-02-01 00:00:00'],
]);
$connection->table('network_stores')->insert([
['network_uuid' => 'network_uuid', 'store_uuid' => 'store_one_uuid'],
['network_uuid' => 'network_uuid', 'store_uuid' => 'store_two_uuid'],
]);
$connection->table('store_locations')->insert([
['uuid' => 'location_one_uuid', 'store_uuid' => 'store_one_uuid'],
['uuid' => 'location_two_uuid', 'store_uuid' => 'store_two_uuid'],
]);
session([
'company' => 'company_uuid',
'storefront_store' => null,
'storefront_network' => 'network_uuid',
]);
$controller = new NetworkController();

// Without a location there is nothing to measure from, so the distance filter no longer
// removes every store and limit/offset stay in SQL.
$withoutLocation = $controller->stores(Request::create('/network/stores', 'GET', [
'sort' => 'oldest',
'maximum_distance' => 1000,
'limit' => 1,
'offset' => 1,
]));
// The coordinate parser's (0, 0) fallback is treated the same as no location.
$nullIsland = $controller->stores(Request::create('/network/stores', 'GET', [
'sort' => 'nearest',
'location' => 'not-a-location',
]));

expect($withoutLocation->resource->pluck('uuid')->all())->toBe(['store_two_uuid'])
->and($nullIsland->resource->pluck('uuid')->sort()->values()->all())->toBe(['store_one_uuid', 'store_two_uuid'])
->and($nullIsland->resource->first()->distance)->toBeNull();
});

test('network stores endpoint preloads each store rating in the store query', function () {
createNetworkApiControllerSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
$connection->table('networks')->insert(['uuid' => 'network_uuid']);
$connection->table('stores')->insert([
['uuid' => 'store_rated_uuid', 'public_id' => 'store_rated', 'company_uuid' => 'company_uuid', 'name' => 'Rated'],
['uuid' => 'store_new_uuid', 'public_id' => 'store_new', 'company_uuid' => 'company_uuid', 'name' => 'New'],
]);
$connection->table('network_stores')->insert([
['network_uuid' => 'network_uuid', 'store_uuid' => 'store_rated_uuid'],
['network_uuid' => 'network_uuid', 'store_uuid' => 'store_new_uuid'],
]);
$connection->table('store_locations')->insert([
['uuid' => 'location_rated_uuid', 'store_uuid' => 'store_rated_uuid'],
['uuid' => 'location_new_uuid', 'store_uuid' => 'store_new_uuid'],
]);
$connection->table('reviews')->insert([
['uuid' => 'review_a', 'subject_uuid' => 'store_rated_uuid', 'rating' => 5],
['uuid' => 'review_b', 'subject_uuid' => 'store_rated_uuid', 'rating' => 4],
]);
session([
'company' => 'company_uuid',
'storefront_store' => null,
'storefront_network' => 'network_uuid',
]);

$stores = (new NetworkController())->stores(Request::create('/network/stores', 'GET'))->resource->keyBy('uuid');

expect(array_key_exists('reviews_avg_rating', $stores['store_rated_uuid']->getAttributes()))->toBeTrue()
->and($stores['store_rated_uuid']->rating)->toBe(4.5)
->and($stores['store_new_uuid']->rating)->toBe(0);
});
22 changes: 22 additions & 0 deletions server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

use Fleetbase\Storefront\Http\Controllers\StoreController;
use Fleetbase\Storefront\Models\Store;
use Illuminate\Http\Request;

test('console store queries eager load what the store resource serializes', function () {
$controller = new StoreController();

$plain = Store::query();
$controller->onQueryRecord($plain, Request::create('/int/v1/stores', 'GET'));

$withCategory = Store::query();
$controller->onQueryRecord($withCategory, Request::create('/int/v1/stores', 'GET', [
'network' => 'network_uuid',
'with_category' => 1,
]));

expect(array_keys($plain->getEagerLoads()))->toBe(['logo', 'backdrop'])
->and($plain->toSql())->toContain('reviews_avg_rating')
->and(array_keys($withCategory->getEagerLoads()))->toBe(['logo', 'backdrop', 'networks']);
});
62 changes: 62 additions & 0 deletions server/tests/Unit/Models/StoreBehaviorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,65 @@ function createStoreBehaviorSchema(): void
expect(fn () => $missing->getOrderConfig())
->toThrow(RuntimeException::class, 'No default OrderConfig is configured.');
});

test('store rating uses a preloaded review average without querying reviews', function () {
// No reviews table: any aggregate query would throw.
Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder()->dropIfExists('reviews');

$decimal = new Store();
$decimal->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => '4.5000']);
$whole = new Store();
$whole->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => 4]);
$unrated = new Store();
$unrated->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => null]);

expect($decimal->rating)->toBe(4.5)
->and($whole->rating)->toBe(4)
->and($unrated->rating)->toBe(0);
});

test('store network category uses eager loaded networks', function () {
createStoreBehaviorSchema();
$connection = Capsule::connection('mysql');
$connection->table('categories')->insert(['uuid' => 'category_uuid', 'name' => 'Electronics']);

$member = new Network();
$member->forceFill(['uuid' => 'network_uuid']);
$member->setRelation('pivot', (object) ['category_uuid' => 'category_uuid']);
$other = new Network();
$other->forceFill(['uuid' => 'other_network_uuid']);

$store = new Store();
$store->forceFill(['uuid' => 'store_uuid']);
$store->setRelation('networks', new Illuminate\Database\Eloquent\Collection([$member]));

// network_stores is empty, so a match can only come from the loaded relation.
expect($store->getNetworkCategory($member)?->uuid)->toBe('category_uuid')
->and($store->getNetworkCategory($other))->toBeNull();
});

test('store resolves each network id once', function () {
createStoreBehaviorSchema();
Store::flushResolvedNetworkIds();
$connection = Capsule::connection('mysql');
$connection->table('stores')->insert(['uuid' => 'store_uuid', 'name' => 'Store']);
$connection->table('networks')->insert(['uuid' => 'network_uuid', 'public_id' => 'network_public', 'name' => 'Network']);
$connection->table('categories')->insert(['uuid' => 'category_uuid', 'name' => 'Flowers']);
$connection->table('network_stores')->insert([
'network_uuid' => 'network_uuid',
'store_uuid' => 'store_uuid',
'category_uuid' => 'category_uuid',
]);
$store = Store::where('uuid', 'store_uuid')->firstOrFail();

expect($store->getNetworkCategoryUsingId('network_public')?->uuid)->toBe('category_uuid');

// The id is not looked up again: it still resolves after the public id changes.
$connection->table('networks')->update(['public_id' => 'network_renamed']);
expect($store->getNetworkCategoryUsingId('network_public')?->uuid)->toBe('category_uuid');

Store::flushResolvedNetworkIds();
expect($store->getNetworkCategoryUsingId('network_public'))->toBeNull();

Store::flushResolvedNetworkIds();
});
Loading