From b2efeee75ead9a4de7fc7ba47037b9ba8ecf2d79 Mon Sep 17 00:00:00 2001 From: Anthony Navarro Date: Sat, 25 Apr 2026 13:18:43 -0700 Subject: [PATCH] Avoid accessing fields that are not guaranteed with api call --- app/DTOs/OccurrenceDTO.php | 20 +++++++++++--------- docs/pbdb-api.md | 2 +- resources/views/occurrences/show.blade.php | 2 +- tests/Unit/DTOs/OccurrenceDTOTest.php | 20 ++++++++++++++++++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/app/DTOs/OccurrenceDTO.php b/app/DTOs/OccurrenceDTO.php index 61adaf4..0765f1c 100644 --- a/app/DTOs/OccurrenceDTO.php +++ b/app/DTOs/OccurrenceDTO.php @@ -8,8 +8,8 @@ class OccurrenceDTO { /** * @param int $occurrenceNo oid — PBDB occurrence number - * @param string $acceptedName tna — accepted taxon name - * @param string $acceptedRank rnk — accepted taxonomic rank + * @param string|null $acceptedName tna — accepted taxon name (absent for some PBDB records) + * @param string|null $acceptedRank rnk — accepted taxonomic rank (absent for some PBDB records) * @param string|null $phylum phl — phylum * @param string|null $class cll — class * @param string|null $order odl — order @@ -31,8 +31,8 @@ class OccurrenceDTO */ public function __construct( public readonly int $occurrenceNo, - public readonly string $acceptedName, - public readonly string $acceptedRank, + public readonly ?string $acceptedName, + public readonly ?string $acceptedRank, public readonly ?string $phylum, public readonly ?string $class, public readonly ?string $order, @@ -104,14 +104,16 @@ private static function parseId(mixed $value): int */ public static function fromArray(array $data): static { - $rawRank = $data['rnk']; - $acceptedRank = is_int($rawRank) - ? (self::RANK_LABELS[$rawRank] ?? (string) $rawRank) - : (string) $rawRank; + $rawRank = $data['rnk'] ?? null; + $acceptedRank = match (true) { + $rawRank === null => null, + is_int($rawRank) => self::RANK_LABELS[$rawRank] ?? (string) $rawRank, + default => (string) $rawRank, + }; return new static( occurrenceNo: self::parseId($data['oid']), - acceptedName: $data['tna'], + acceptedName: $data['tna'] ?? null, acceptedRank: $acceptedRank, phylum: $data['phl'] ?? null, class: $data['cll'] ?? null, diff --git a/docs/pbdb-api.md b/docs/pbdb-api.md index 3e3a672..f13db66 100644 --- a/docs/pbdb-api.md +++ b/docs/pbdb-api.md @@ -236,7 +236,7 @@ The concrete implementation of `FossilOccurrenceServiceInterface`. Injected with A **readonly value object** representing a single fossil occurrence record. Created by `OccurrenceDTO::fromArray()` which maps PBDB's terse field codes (e.g. `oid`, `tna`, `phl`) to readable property names. -All fields except `occurrenceNo`, `acceptedName`, `acceptedRank`, and `collectionNo` are nullable — PBDB does not guarantee every field is present in every record. +All fields except `occurrenceNo` and `collectionNo` are nullable — PBDB does not guarantee every field is present in every record. `acceptedName` and `acceptedRank` are both nullable; when a taxon has not been entered into PBDB taxonomy (`tdf: 'taxon not entered'`), both `tna` and `rnk` are omitted. This is common in deep-time queries (e.g. Cambrian Seas and Great Dying presets). The `paleolat` / `paleolng` properties are only populated when `paleoloc` is in the `show` parameter. diff --git a/resources/views/occurrences/show.blade.php b/resources/views/occurrences/show.blade.php index f16a5c9..140e1a9 100644 --- a/resources/views/occurrences/show.blade.php +++ b/resources/views/occurrences/show.blade.php @@ -23,7 +23,7 @@ class="inline-flex items-center gap-1 text-sm text-muted hover:text-text transit - {{ ucfirst($occurrence->acceptedRank) }} + {{ $occurrence->acceptedRank ? ucfirst($occurrence->acceptedRank) : 'Unknown' }} @php diff --git a/tests/Unit/DTOs/OccurrenceDTOTest.php b/tests/Unit/DTOs/OccurrenceDTOTest.php index e0b28a6..b4d3918 100644 --- a/tests/Unit/DTOs/OccurrenceDTOTest.php +++ b/tests/Unit/DTOs/OccurrenceDTOTest.php @@ -212,6 +212,26 @@ public function test_from_array_preserves_string_rank_unchanged(): void $this->assertSame('clade', $dto->acceptedRank); } + public function test_from_array_sets_null_rank_when_rnk_key_absent(): void + { + $record = $this->fullRecord; + unset($record['rnk']); + + $dto = OccurrenceDTO::fromArray($record); + + $this->assertNull($dto->acceptedRank); + } + + public function test_from_array_sets_null_accepted_name_when_tna_key_absent(): void + { + $record = $this->fullRecord; + unset($record['tna']); + + $dto = OccurrenceDTO::fromArray($record); + + $this->assertNull($dto->acceptedName); + } + public function test_from_array_maps_paleolat_and_paleolng(): void { $record = array_merge($this->fullRecord, [