move_detect/cloud/src/Repository/Pdo/PdoZoneRepository.php
유창욱 c1fb22ee16 fix(cloud): deterministic event ordering + zone concurrent-delete detection
Self-review (Task 16) follow-up fixes:
- PdoEventRepository::recent(): add event_id DESC as a secondary sort key
  after occurred_at DESC so ties (equal device-reported timestamps) resolve
  deterministically, matching InMemoryEventRepository's insertion-order
  tie-break.
- PdoZoneRepository::updateOwned(): after the ownership-scoped UPDATE,
  disambiguate a 0-rowCount() result (MySQL reports "changed" rows, not
  "matched" rows) with a targeted existence check, so a genuine no-op
  resubmit (identical level/polygon) still succeeds while a zone deleted
  concurrently between the ownership check and the UPDATE now throws
  instead of save() silently reporting success for a zone that no longer
  exists.

Full suite still green: 59 tests, 114 assertions, 4 skipped, 0 failures,
0 warnings/deprecations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
2026-07-12 02:06:32 +09:00

109 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\ZoneRepository;
use PDO;
use RuntimeException;
final class PdoZoneRepository implements ZoneRepository
{
public function __construct(private PDO $pdo) {}
public function forDevice(string $deviceId): array
{
$st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE device_id = ?'
);
$st->execute([$deviceId]);
return $st->fetchAll();
}
public function forCamera(string $cameraId): array
{
$st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE camera_id = ?'
);
$st->execute([$cameraId]);
return $st->fetchAll();
}
public function save(array $zone): string
{
$zoneId = $zone['zone_id'] ?? null;
$deviceId = (string)($zone['device_id'] ?? '');
$cameraId = (string)($zone['camera_id'] ?? '');
$level = (string)($zone['level'] ?? '');
$polygonJson = (string)($zone['polygon_json'] ?? '[]');
if (is_string($zoneId) && $zoneId !== '') {
return $this->updateOwned($zoneId, $deviceId, $cameraId, $level, $polygonJson);
}
$newId = bin2hex(random_bytes(16));
$st = $this->pdo->prepare(
'INSERT INTO zones (zone_id, device_id, camera_id, level, polygon_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?)'
);
$st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]);
return $newId;
}
public function delete(string $zoneId): bool
{
$st = $this->pdo->prepare('DELETE FROM zones WHERE zone_id = ?');
$st->execute([$zoneId]);
return $st->rowCount() > 0;
}
/**
* Update path for save(): scoped by (zone_id, device_id, camera_id) so a
* caller cannot overwrite an unrelated zone by supplying its id with a
* different device_id/camera_id (closes the cross-tenant overwrite risk
* flagged in the Task 13 review). Ownership is verified with a SELECT
* before the UPDATE (rather than trusting UPDATE's rowCount()) because
* MySQL reports "changed rows", not "matched rows" -- a no-op update
* (identical level/polygon resubmitted) would otherwise report 0 affected
* rows and be indistinguishable from an ownership mismatch. The UPDATE's
* rowCount() is still checked afterwards (0 there is unambiguous, since
* we already know the row existed a moment ago) to catch the narrow race
* where the zone is deleted between the ownership check and the UPDATE --
* without this, save() would return success for a zone that no longer
* exists.
*/
private function updateOwned(
string $zoneId,
string $deviceId,
string $cameraId,
string $level,
string $polygonJson,
): string {
$owns = $this->pdo->prepare(
'SELECT 1 FROM zones WHERE zone_id = ? AND device_id = ? AND camera_id = ? LIMIT 1'
);
$owns->execute([$zoneId, $deviceId, $cameraId]);
if ($owns->fetchColumn() === false) {
throw new RuntimeException('zone not found or not owned by the given device/camera');
}
$st = $this->pdo->prepare(
'UPDATE zones SET level = ?, polygon_json = ?, updated_at = ?
WHERE zone_id = ? AND device_id = ? AND camera_id = ?'
);
$st->execute([$level, $polygonJson, time(), $zoneId, $deviceId, $cameraId]);
if ($st->rowCount() === 0) {
// Ambiguous on its own: MySQL's rowCount() reports "changed" rows,
// not "matched" rows, so 0 here could mean either (a) a genuine
// no-op update (identical level/polygon resubmitted -- not an
// error) or (b) the zone was deleted between the ownership check
// above and this UPDATE. Disambiguate with a targeted existence
// check instead of assuming either case.
$stillExists = $this->pdo->prepare('SELECT 1 FROM zones WHERE zone_id = ? LIMIT 1');
$stillExists->execute([$zoneId]);
if ($stillExists->fetchColumn() === false) {
throw new RuntimeException('zone was deleted concurrently');
}
}
return $zoneId;
}
}