From e4a85d749d5d3447d2162d7d248b6ed02fd645f4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:35:28 +0800 Subject: [PATCH 1/2] fix(models): declare relation return types the console requests Core's RequestRelations now loads a client-requested `with`/`expand`/`with_count` name only when the method (or Expandable closure) declares an Eloquent Relation return type, or the model lists it in `$withable`. These relations are requested by the console and the public v1 expansions but were untyped, so they would stop loading: - Fleet: photo, serviceArea, zone, vendor, parentFleet (BelongsTo); subFleets (HasMany); drivers, vehicles (HasManyThrough) - Issue: reporter, assignee, vehicle, driver (BelongsTo) - FuelReport: driver, vehicle, reporter (BelongsTo) - ServiceRate: rateFees, parcelFees (HasMany); serviceArea, zone (BelongsTo) - Driver: vehicle (BelongsTo) - Payload: entities (HasMany); dropoff, pickup, return (BelongsTo); waypoints (HasManyThrough) - UserExpansion closures: driver, currentDriverSession, customer, contact (HasOne); driverProfiles (HasMany). CompanyExpansion: drivers (HasMany) Test fakes that override these methods now declare the same return type and extend the matching relation class, since an untyped override of a typed parent method is a fatal error at class load. --- server/src/Expansions/CompanyExpansion.php | 5 ++- server/src/Expansions/UserExpansion.php | 22 +++++----- server/src/Models/Driver.php | 5 +-- server/src/Models/Fleet.php | 43 +++++-------------- server/src/Models/FuelReport.php | 18 +++----- server/src/Models/Issue.php | 20 ++------- server/src/Models/Payload.php | 15 ++++--- server/src/Models/ServiceRate.php | 24 +++-------- server/tests/ExpansionContractsTest.php | 17 +++++++- server/tests/ModelAccessorContractsTest.php | 7 +-- .../tests/OrderActivityFlowRegressionTest.php | 5 ++- server/tests/Unit/Models/FleetTest.php | 7 +-- server/tests/Unit/Models/PayloadTest.php | 22 ++++++++-- ...iceRatePersistenceAndServicabilityTest.php | 4 +- 14 files changed, 97 insertions(+), 117 deletions(-) diff --git a/server/src/Expansions/CompanyExpansion.php b/server/src/Expansions/CompanyExpansion.php index 5c277d3c6..683093555 100644 --- a/server/src/Expansions/CompanyExpansion.php +++ b/server/src/Expansions/CompanyExpansion.php @@ -4,6 +4,7 @@ use Fleetbase\Build\Expansion; use Fleetbase\FleetOps\Models\Driver; +use Illuminate\Database\Eloquent\Relations\HasMany; class CompanyExpansion implements Expansion { @@ -18,11 +19,11 @@ public static function target() } /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public static function drivers() { - return function () { + return function (): HasMany { /** @var \Illuminate\Database\Eloquent\Model $this */ return $this->hasMany(Driver::class); }; diff --git a/server/src/Expansions/UserExpansion.php b/server/src/Expansions/UserExpansion.php index 22be64926..55b3fd26f 100644 --- a/server/src/Expansions/UserExpansion.php +++ b/server/src/Expansions/UserExpansion.php @@ -5,6 +5,8 @@ use Fleetbase\Build\Expansion; use Fleetbase\FleetOps\Models\Contact; use Fleetbase\FleetOps\Models\Driver; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; class UserExpansion implements Expansion { @@ -19,55 +21,55 @@ public static function target() } /** - * @return \Illuminate\Database\Eloquent\Relations\HasOne + * @return HasOne */ public static function driver() { - return function () { + return function (): HasOne { /** @var \Illuminate\Database\Eloquent\Model $this */ return $this->hasOne(Driver::class)->without('user'); }; } /** - * @return \Illuminate\Database\Eloquent\Relations\HasOne + * @return HasOne */ public static function currentDriverSession() { - return function () { + return function (): HasOne { /** @var \Illuminate\Database\Eloquent\Model $this */ return $this->driver()->where('company_uuid', session('company')); }; } /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public static function driverProfiles() { - return function () { + return function (): HasMany { /** @var \Illuminate\Database\Eloquent\Model $this */ return $this->hasMany(Driver::class); }; } /** - * @return \Illuminate\Database\Eloquent\Relations\HasOne + * @return HasOne */ public static function customer() { - return function () { + return function (): HasOne { /** @var \Illuminate\Database\Eloquent\Model $this */ return $this->hasOne(Contact::class, 'user_uuid', 'uuid')->where('type', 'customer')->without('user'); }; } /** - * @return \Illuminate\Database\Eloquent\Relations\HasOne + * @return HasOne */ public static function contact() { - return function () { + return function (): HasOne { /** @var \Illuminate\Database\Eloquent\Model $this */ return $this->hasOne(Contact::class, 'user_uuid', 'uuid')->without('user'); }; diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index c796257fa..8dedf68a1 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -272,10 +272,7 @@ public function company() return $this->belongsTo(\Fleetbase\Models\Company::class); } - /** - * @return BelongsTo - */ - public function vehicle() + public function vehicle(): BelongsTo { return $this->belongsTo(Vehicle::class)->select([ 'uuid', diff --git a/server/src/Models/Fleet.php b/server/src/Models/Fleet.php index b2ba0ac2a..c76a749c8 100644 --- a/server/src/Models/Fleet.php +++ b/server/src/Models/Fleet.php @@ -11,6 +11,9 @@ use Fleetbase\Traits\Searchable; use Fleetbase\Traits\SendsWebhooks; use Fleetbase\Traits\TracksApiCredential; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Notifications\Notifiable; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; @@ -105,66 +108,42 @@ public function getSlugOptions(): SlugOptions */ protected $hidden = []; - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function photo() + public function photo(): BelongsTo { return $this->belongsTo(\Fleetbase\Models\File::class, 'image_uuid', 'uuid'); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function serviceArea() + public function serviceArea(): BelongsTo { return $this->belongsTo(ServiceArea::class)->select(['uuid', 'public_id', 'name', 'type', 'border']); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function zone() + public function zone(): BelongsTo { return $this->belongsTo(Zone::class)->select(['uuid', 'public_id', 'name', 'border']); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function vendor() + public function vendor(): BelongsTo { return $this->belongsTo(Vendor::class)->select(['uuid', 'public_id', 'name']); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function parentFleet() + public function parentFleet(): BelongsTo { return $this->belongsTo(Fleet::class)->without(['subFleets']); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function subFleets() + public function subFleets(): HasMany { return $this->hasMany(Fleet::class, 'parent_fleet_uuid', 'uuid')->without(['parentFleet']); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough - */ - public function drivers() + public function drivers(): HasManyThrough { return $this->hasManyThrough(Driver::class, FleetDriver::class, 'fleet_uuid', 'uuid', 'uuid', 'driver_uuid'); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough - */ - public function vehicles() + public function vehicles(): HasManyThrough { return $this->hasManyThrough(Vehicle::class, FleetVehicle::class, 'fleet_uuid', 'uuid', 'uuid', 'vehicle_uuid'); } diff --git a/server/src/Models/FuelReport.php b/server/src/Models/FuelReport.php index 8c7b0b669..fe6bbeda8 100644 --- a/server/src/Models/FuelReport.php +++ b/server/src/Models/FuelReport.php @@ -16,6 +16,7 @@ use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\Searchable; use Fleetbase\Traits\TracksApiCredential; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class FuelReport extends Model { @@ -118,34 +119,25 @@ public function setAmountAttribute($value) $this->attributes['amount'] = Utils::numbersOnly($value); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function driver() + public function driver(): BelongsTo { return $this->belongsTo(Driver::class)->without(['vehicle']); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function vehicle() + public function vehicle(): BelongsTo { return $this->belongsTo(Vehicle::class)->without(['driver']); } /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function reportedBy() { return $this->belongsTo(User::class); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function reporter() + public function reporter(): BelongsTo { return $this->belongsTo(User::class, 'reported_by_uuid'); } diff --git a/server/src/Models/Issue.php b/server/src/Models/Issue.php index 6596a2a0c..00780bc8a 100644 --- a/server/src/Models/Issue.php +++ b/server/src/Models/Issue.php @@ -152,10 +152,7 @@ public function reportedBy() return $this->belongsTo(User::class); } - /** - * @return BelongsTo - */ - public function reporter() + public function reporter(): BelongsTo { return $this->belongsTo(User::class, 'reported_by_uuid'); } @@ -168,26 +165,17 @@ public function assignedTo() return $this->belongsTo(User::class); } - /** - * @return BelongsTo - */ - public function assignee() + public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assigned_to_uuid'); } - /** - * @return BelongsTo - */ - public function vehicle() + public function vehicle(): BelongsTo { return $this->belongsTo(Vehicle::class); } - /** - * @return BelongsTo - */ - public function driver() + public function driver(): BelongsTo { return $this->belongsTo(Driver::class); } diff --git a/server/src/Models/Payload.php b/server/src/Models/Payload.php index de158cc0f..d15a88f2b 100644 --- a/server/src/Models/Payload.php +++ b/server/src/Models/Payload.php @@ -17,6 +17,9 @@ use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\TracksApiCredential; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -140,7 +143,7 @@ public function getReturnNameAttribute() /** * Entities in the payload. */ - public function entities() + public function entities(): HasMany { return $this->hasMany(Entity::class); } @@ -194,7 +197,7 @@ public function order() /** * The address the shipment will be delivered to. */ - public function dropoff() + public function dropoff(): BelongsTo { return $this->belongsTo(Place::class, 'dropoff_uuid')->whereNull('deleted_at')->withoutGlobalScopes(); } @@ -202,7 +205,7 @@ public function dropoff() /** * The address the shipment will be delivered from. */ - public function pickup() + public function pickup(): BelongsTo { return $this->belongsTo(Place::class, 'pickup_uuid')->whereNull('deleted_at')->withoutGlobalScopes(); } @@ -210,7 +213,7 @@ public function pickup() /** * The address the shipment will be sent to upon failed delivery. */ - public function return() + public function return(): BelongsTo { return $this->belongsTo(Place::class)->withoutGlobalScopes(); } @@ -233,10 +236,8 @@ public function currentWaypointMarker() /** * Waypoints between start and end. - * - * @return \Illuminate\Database\Eloquent\Concerns\HasManyThrough */ - public function waypoints() + public function waypoints(): HasManyThrough { return $this->hasManyThrough(Place::class, Waypoint::class, 'payload_uuid', 'uuid', 'uuid', 'place_uuid')->whereNull('waypoints.deleted_at')->withoutGlobalScopes(); } diff --git a/server/src/Models/ServiceRate.php b/server/src/Models/ServiceRate.php index 043a49dcb..56169ad96 100644 --- a/server/src/Models/ServiceRate.php +++ b/server/src/Models/ServiceRate.php @@ -12,6 +12,8 @@ use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\SendsWebhooks; use Fleetbase\Traits\TracksApiCredential; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class ServiceRate extends Model { @@ -110,42 +112,30 @@ class ServiceRate extends Model */ protected $filterParams = []; - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function rateFees() + public function rateFees(): HasMany { return $this->hasMany(ServiceRateFee::class); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function parcelFees() + public function parcelFees(): HasMany { return $this->hasMany(ServiceRateParcelFee::class); } /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function orderConfig() { return $this->belongsTo(OrderConfig::class)->withTrashed(); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function serviceArea() + public function serviceArea(): BelongsTo { return $this->belongsTo(ServiceArea::class)->whereNull('deleted_at'); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function zone() + public function zone(): BelongsTo { return $this->belongsTo(Zone::class); } diff --git a/server/tests/ExpansionContractsTest.php b/server/tests/ExpansionContractsTest.php index 5f1b4f0f9..e5200047e 100644 --- a/server/tests/ExpansionContractsTest.php +++ b/server/tests/ExpansionContractsTest.php @@ -11,7 +11,9 @@ function bindFleetOpsExpansionClosure(Closure $closure, object $target): Closure return $closure->bindTo($target, $target::class); } -class FleetOpsExpansionRelationFake +// The expansion closures declare HasOne / HasMany return types, so the fakes extend +// those relations; the parent constructor is skipped on purpose. +trait FleetOpsExpansionRecordsRelationCalls { public array $calls = []; @@ -34,6 +36,16 @@ public function where($column, $operator = null, $value = null) } } +class FleetOpsExpansionRelationFake extends Illuminate\Database\Eloquent\Relations\HasOne +{ + use FleetOpsExpansionRecordsRelationCalls; +} + +class FleetOpsExpansionManyRelationFake extends Illuminate\Database\Eloquent\Relations\HasMany +{ + use FleetOpsExpansionRecordsRelationCalls; +} + class FleetOpsExpansionModelFake { public array $calls = []; @@ -41,9 +53,10 @@ class FleetOpsExpansionModelFake public function __construct() { - foreach (['driver', 'driverProfiles', 'customer', 'contact'] as $name) { + foreach (['driver', 'customer', 'contact'] as $name) { $this->relations[$name] = new FleetOpsExpansionRelationFake($name); } + $this->relations['driverProfiles'] = new FleetOpsExpansionManyRelationFake('driverProfiles'); } public function hasMany($class) diff --git a/server/tests/ModelAccessorContractsTest.php b/server/tests/ModelAccessorContractsTest.php index c40fff6cd..ca9fd6052 100644 --- a/server/tests/ModelAccessorContractsTest.php +++ b/server/tests/ModelAccessorContractsTest.php @@ -85,7 +85,8 @@ function fleetopsModelAccessorsUseInMemoryRelationConnection(): void EloquentModel::setConnectionResolver($resolver); } -class FleetOpsCountingRelationFake +// Extends the relation the model method now declares, so the override stays compatible; the parent constructor is skipped on purpose. +class FleetOpsCountingRelationFake extends HasManyThrough { public function __construct(private int $total, private int $online) { @@ -251,12 +252,12 @@ public function loadMissing($relations) class FleetOpsFleetAccessorFake extends Fleet { - public function drivers() + public function drivers(): HasManyThrough { return new FleetOpsCountingRelationFake(5, 2); } - public function vehicles() + public function vehicles(): HasManyThrough { return new FleetOpsCountingRelationFake(7, 3); } diff --git a/server/tests/OrderActivityFlowRegressionTest.php b/server/tests/OrderActivityFlowRegressionTest.php index 8a3d75cef..7eff0accf 100644 --- a/server/tests/OrderActivityFlowRegressionTest.php +++ b/server/tests/OrderActivityFlowRegressionTest.php @@ -20,7 +20,8 @@ use Fleetbase\FleetOps\Support\ResolvesOrderServiceStops; use Fleetbase\LaravelMysqlSpatial\Types\Point; -class ActivityFlowRelationCountFake +// Extends the relation the model method now declares, so the override stays compatible; the parent constructor is skipped on purpose. +class ActivityFlowRelationCountFake extends Illuminate\Database\Eloquent\Relations\HasManyThrough { public function __construct(private int $count) { @@ -67,7 +68,7 @@ public function load($relations) return $this; } - public function waypoints() + public function waypoints(): Illuminate\Database\Eloquent\Relations\HasManyThrough { return new ActivityFlowRelationCountFake($this->waypoints?->count() ?? 0); } diff --git a/server/tests/Unit/Models/FleetTest.php b/server/tests/Unit/Models/FleetTest.php index 4d1a40873..3849900cf 100644 --- a/server/tests/Unit/Models/FleetTest.php +++ b/server/tests/Unit/Models/FleetTest.php @@ -33,7 +33,8 @@ function session($key = null, $default = null) use Spatie\Activitylog\LogOptions; use Spatie\Sluggable\SlugOptions; -class FleetOpsFleetUnitCountingRelationFake +// Extends the relation the model method now declares, so the override stays compatible; the parent constructor is skipped on purpose. +class FleetOpsFleetUnitCountingRelationFake extends HasManyThrough { public array $wheres = []; @@ -59,7 +60,7 @@ class FleetOpsFleetUnitCountingFake extends Fleet public array $driverRelations = []; public array $vehicleRelations = []; - public function drivers() + public function drivers(): HasManyThrough { $relation = new FleetOpsFleetUnitCountingRelationFake(5); $this->driverRelations[] = $relation; @@ -67,7 +68,7 @@ public function drivers() return $relation; } - public function vehicles() + public function vehicles(): HasManyThrough { $relation = new FleetOpsFleetUnitCountingRelationFake(8); $this->vehicleRelations[] = $relation; diff --git a/server/tests/Unit/Models/PayloadTest.php b/server/tests/Unit/Models/PayloadTest.php index edf14cd7f..ac3627758 100644 --- a/server/tests/Unit/Models/PayloadTest.php +++ b/server/tests/Unit/Models/PayloadTest.php @@ -32,7 +32,8 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\SQLiteConnection; -class FleetOpsPayloadUnitRelationCountFake +// Extends the relation the model method now declares, so the override stays compatible; the parent constructor is skipped on purpose. +class FleetOpsPayloadUnitRelationCountFake extends HasManyThrough { public function __construct(private readonly Payload $payload, private readonly string $relation) { @@ -44,6 +45,19 @@ public function count(): int } } +// Extends the relation the model method now declares, so the override stays compatible; the parent constructor is skipped on purpose. +class FleetOpsPayloadUnitEntityCountFake extends HasMany +{ + public function __construct(private readonly Payload $payload) + { + } + + public function count(): int + { + return $this->payload->getRelation('entities')?->count() ?? 0; + } +} + class FleetOpsPayloadUnitDatabaseProbe { public function __construct(private SQLiteConnection $connection) @@ -147,14 +161,14 @@ class FleetOpsPayloadUnitFake extends Payload public array $activityUpdates = []; public array $currentWaypointWrites = []; - public function waypoints() + public function waypoints(): HasManyThrough { return new FleetOpsPayloadUnitRelationCountFake($this, 'waypoints'); } - public function entities() + public function entities(): HasMany { - return new FleetOpsPayloadUnitRelationCountFake($this, 'entities'); + return new FleetOpsPayloadUnitEntityCountFake($this); } public function hasMeta($key): bool diff --git a/server/tests/Unit/Models/ServiceRatePersistenceAndServicabilityTest.php b/server/tests/Unit/Models/ServiceRatePersistenceAndServicabilityTest.php index 25b5755cb..dd39bcd15 100644 --- a/server/tests/Unit/Models/ServiceRatePersistenceAndServicabilityTest.php +++ b/server/tests/Unit/Models/ServiceRatePersistenceAndServicabilityTest.php @@ -92,12 +92,12 @@ public function getAttribute($key) return parent::getAttribute($key); } - public function rateFees() + public function rateFees(): Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(Fleetbase\FleetOps\Models\ServiceRateFee::class, 'service_rate_uuid', 'uuid'); } - public function parcelFees() + public function parcelFees(): Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(Fleetbase\FleetOps\Models\ServiceRateParcelFee::class, 'service_rate_uuid', 'uuid'); } From 45fab851aacdcf7d40001080100442e3dcd9b81d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:35:28 +0800 Subject: [PATCH 2/2] fix(resources): only load requested relations core accepts ResolvesPublicRelationFields::requestedRelations() kept any `with` name whose root was a method on the model, and the Fleet and Vehicle resources pass the result to loadMissing(). Eloquent loads a name by calling it on a blank model, so `?with=saveInstance` ran that method. When Fleetbase\Support\RequestRelations is available, an Eloquent resource now keeps only the paths RequestRelations::eagerLoads() accepts, named as the model's methods, which also matches the canonical names publicRelationObject() compares against. The method_exists check stays as the fallback for core releases without RequestRelations and for non-Eloquent resources. --- .../Concerns/ResolvesPublicRelationFields.php | 15 +++++- server/tests/VehicleResourceContractsTest.php | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php b/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php index a6250451e..766dee847 100644 --- a/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +++ b/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php @@ -3,6 +3,8 @@ namespace Fleetbase\FleetOps\Http\Resources\v1\Concerns; use Fleetbase\Support\Http; +use Fleetbase\Support\RequestRelations; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Resources\MissingValue; /** @@ -30,6 +32,13 @@ trait ResolvesPublicRelationFields * model actually defines are kept — a parent's expansion must never reach * this model's loader, where it raises RelationNotFoundException. * + * The result is handed to `loadMissing()`, and Eloquent loads a name by + * calling it as a method on a blank model, so a name that is merely a + * method (`with=saveInstance`) would run it. When core's RequestRelations + * is available an Eloquent model keeps only declared relations, each + * segment named as the model's method (`service_area` → `serviceArea`). + * Older core releases without it keep the root-method check below. + * * @return array */ protected function requestedRelations($request): array @@ -46,6 +55,10 @@ protected function requestedRelations($request): array $with = array_values(array_filter(array_map('strval', $with), 'strlen')); + if ($this->resource instanceof Model && class_exists(RequestRelations::class)) { + return RequestRelations::eagerLoads($this->resource, $with); + } + return array_values(array_filter($with, fn (string $relation): bool => $this->definesRelation($relation))); } @@ -77,7 +90,7 @@ private function definesRelation(string $relation): bool * loaded on the public endpoints, so the key was absent unless `with` named * it. Absent stays absent; it never becomes a string. * - * @param array $with camelCased relations the caller asked for + * @param array $with the relation paths requestedRelations() kept, named as the model's methods */ protected function publicRelationObject(string $relation, array $with, \Closure $resource): mixed { diff --git a/server/tests/VehicleResourceContractsTest.php b/server/tests/VehicleResourceContractsTest.php index 5757f1805..3cb178b17 100644 --- a/server/tests/VehicleResourceContractsTest.php +++ b/server/tests/VehicleResourceContractsTest.php @@ -350,3 +350,49 @@ public function driver(): void ->and(fleetopsVehicleResourceRequestedRelations((object) ['uuid' => 'vehicle-uuid'], ['with' => ['driver']]))->toBe([]) ->and(fleetopsVehicleResourceRequestedRelations(fleetopsVehicleResourceFixture(), ['with' => ['driver']]))->toBe([]); }); + +class FleetOpsVehicleResourceRelationModel extends Illuminate\Database\Eloquent\Model +{ + public static int $saved = 0; + + protected $table = 'vehicles'; + + public function driver(): Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(FleetOpsVehicleResourceRelationModel::class, 'driver_id'); + } + + public function parentVehicle(): Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(FleetOpsVehicleResourceRelationModel::class, 'parent_id'); + } + + public function untypedDriver() + { + return $this->belongsTo(FleetOpsVehicleResourceRelationModel::class, 'driver_id'); + } + + public function saveInstance(): self + { + static::$saved++; + + return $this; + } +} + +test('requested relations drop names that are methods but not declared relations', function () { + $resolver = new Illuminate\Database\ConnectionResolver(['default' => new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'))]); + $resolver->setDefaultConnection('default'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); + FleetOpsVehicleResourceRelationModel::$saved = 0; + + $vehicle = new FleetOpsVehicleResourceRelationModel(); + + // `with=saveInstance` used to reach loadMissing(), which calls the method on a + // blank model. Only methods that declare a Relation return type survive, named + // as the model declares them, and nested segments are checked the same way. + expect(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => ['driver', 'saveInstance', 'untypedDriver', 'delete', 'driver.saveInstance', 'parent_vehicle', 'payload']])) + ->toBe(['driver', 'parentVehicle']) + ->and(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => 'saveInstance']))->toBe([]) + ->and(FleetOpsVehicleResourceRelationModel::$saved)->toBe(0); +})->skip(fn () => !class_exists(Fleetbase\Support\RequestRelations::class), 'needs fleetbase/core-api with Fleetbase\Support\RequestRelations');