Skip to content

fix: treat an empty relationship as absent instead of raising - #313

Merged
roncodes merged 1 commit into
feature/public-fleet-resource-apifrom
fix/public-api-null-relationship-inputs
Sep 6, 2026
Merged

fix: treat an empty relationship as absent instead of raising#313
roncodes merged 1 commit into
feature/public-fleet-resource-apifrom
fix/public-api-null-relationship-inputs

Conversation

@roncodes

@roncodes roncodes commented Sep 6, 2026

Copy link
Copy Markdown
Member

Summary

An empty relationship — "driver": "" on POST /v1/orders — returns a 500 instead of being ignored. This restores the historical behaviour and adds a test that prevents the whole class from coming back.

Targets feature/public-fleet-resource-api (#311).

What broke, and when

ConvertEmptyStringsToNull is global middleware, so "driver": "" reaches the controller as null, and $request->has('driver') is still true — has() means the key is present, not that it holds anything. A null therefore arrives at the lookup on every request where a client serialises an unselected dropdown as an empty string.

That was harmless for years because the lookup was a plain query: where('public_id', null) matches nothing, and the caller's if ($driver) skipped the assignment. The order was created without a driver, which is what the caller meant.

It broke in 2c7de8fb (coverage refactor, released in v0.6.59), which extracted the inline query into a helper and typed it string $publicId while making only the return nullable:

protected function findDriverByPublicId(string $publicId): ?Driver

Nothing about the lookup changed — only the parameter's willingness to be asked. "No driver" became a TypeError.

Worth noting what did not break: vehicle, three lines below driver in the same block, still goes through Utils::getUuid() and has always answered null for a null. The regression is specific to the seams that were extracted, not to the assignment pattern.

The fix

Widen the parameter on the eight lookup seams that are fed directly from request input:

Controller Seam
OrderController findDriverByPublicId
EntityController findPayloadByPublicId
TrackingStatusController getOrderTrackingNumberUuid
ServiceAreaController / ZoneController serviceAreaUuid
VehicleController findDriver
IssueController / FuelReportController findDriverRecord

Every body is unchanged, byte for byte. Each was already null-safe — where('public_id', null) matches nothing, serviceAreaUuid never reads $publicId at all, and findRecordOrFail reports not-found, which its call sites already turn into a 404. The only thing the narrow type added was the crash.

Also adds nullable to service_area on CreateServiceRateRequest, so an empty optional relationship reads as absent rather than answering "The selected service area is invalid." zone on the same request has the identical defect and is fixed with it — say the word if you'd rather I split it out.

Compatibility

A widened parameter is strictly more permissive: every call that worked before passes the same value into the same body and gets the same answer. Only the calls that previously raised a TypeError behave differently, and they now do what they did before v0.6.59.

Deliberately not changed: the has() / empty() guards on update paths. Switching them to filled() would have been the tidier-looking fix and would have silently broken clearing — PUT {"vendor": ""} must keep setting vendor_id to null, and VehicleController::update must keep calling unassignDriver() on an empty driver. Those semantics are verified and untouched.

Of the eight seams, three are reachable with a null over HTTP today (Order, Entity, TrackingStatus); the other five are already guarded by !empty or by a required validation rule. Widening them is defence-in-depth so the next call site added against them cannot reintroduce the bug.

Tests

NullRelationshipInputTest has three tests:

  • Each seam answers rather than raising when handed null.
  • A real identifier still resolves, and a missing one still resolves to nothing — widening the parameter did not widen the lookup.
  • A systemic guard that derives the seam list instead of pinning it: it finds every $this->seam($request->input(...)) call in the public v1 controllers and fails if the first parameter is non-nullable. Exemptions require a named call-site guard, so the list can't quietly rot into a rubber stamp.

The guard was mutation-checked: reverting findDriverByPublicId to string fails it with the exact original signature named. That is the test that would have caught 2c7de8fb at the commit that introduced it.

Coverage stays at 100%.

`ConvertEmptyStringsToNull` turns `"driver": ""` into null before the
controller sees it, and `has('driver')` is still true afterwards — `has()`
reports that the key is present, not that it holds anything. A null therefore
reaches these lookups whenever a client serialises an unselected field as an
empty string.

That was harmless for years: the lookups were plain queries, `where('public_id',
null)` matched nothing, and the caller's `if ($driver)` skipped the assignment.
2c7de8f extracted those queries into helpers and typed them `string` while
making only the return nullable, so "no driver" became a TypeError. Note that
`vehicle`, assigned three lines below `driver` via `Utils::getUuid()`, never
broke — the regression belongs to the extraction, not to the pattern.

Widen the eight seams fed directly from request input. Every body is unchanged
and each was already null-safe, so this adds no statements and no new
behaviour: a widened parameter is strictly more permissive, and the only calls
that behave differently are the ones that used to crash.

The `has()`/`empty()` guards on update paths are deliberately untouched.
Switching them to `filled()` would read as the tidier fix and would silently
break clearing — `VehicleController::update` unassigns a driver on an empty
value, and `PUT {"vendor": ""}` must keep nulling `vendor_id`.

Also mark `service_area` and `zone` nullable on service rate creation. Both are
optional, so an empty one has to read as absent rather than draw "the selected
service area is invalid".

The regression test derives its seam list by scanning for
`$this->seam($request->input(...))` rather than pinning today's eight, so a
future extraction that types a new seam `string` fails at the commit that
introduces it. Exemptions have to name the call-site guard that excludes null,
and the scan asserts it actually matched something so it cannot pass vacuously.
@roncodes
roncodes merged commit 26fc150 into feature/public-fleet-resource-api Sep 6, 2026
@roncodes
roncodes deleted the fix/public-api-null-relationship-inputs branch September 6, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant