feat: catalog shared contracts (Tasks 1-4)
Task 1: Establish catalog identity and atomic file ownership - CatalogService, CatalogServiceInterface, ModelMetadata DTO - PdoCatalogRepository with atomic operations, savepoint support - FOR UPDATE file locks in ascending order, FOR NO KEY UPDATE for metadata - FK-compatible lock composition documented (spec §6) Task 2: M3 import & indexing engine delivery layer - ImportService, ImportWorker with durable collision handling - SpaceChecker, StreamingHasher, VerifiedCopier, StlValidator - FileEnumerator, LocalFilesystem, PathSafety - Console command wiring via ContainerWiringTest Task 3: Importer integration and repair - LegacyGuessedImportGroupingAdapter isolates first-directory guessing - ImportService uses adapter; ImportWorker uses CatalogServiceInterface - Null owners remain null; DomainException mapped to CATALOG_CONFLICT - SQL failure after registration/path update with caller sentinel rollback Task 4: PostgreSQL contention evidence and bounded acceptance - 6 new PostgresCatalogContractsTest cases with independent sessions - Generated schemas, 250ms lock timeouts, 5s statement timeouts - Ascending file locks, FOR SHARE contention, stale-owner rejection - Rollback/retry, nested transaction survival, root creation contention - Negative controls: replay FOR UPDATE removal (11 failures), metadata FOR UPDATE (4 FK KEY SHARE timeouts) - Fresh Docker PostgreSQL 17 / PHP 8.4.26: 473 tests, 2718 assertions, 10 skips, exit 0 All local: 474 tests, 2347 assertions, 54 skips, foundation/lint clean.
This commit is contained in:
1 parent
6b9fb54b4f
commit
a829d6e8c6
21 files changed
+1903
-61
No files matched your search
@@ -6,12 +6,54 @@ namespace MeshVault\Catalog;
|
||||
|
||||
use MeshVault\Repository\CatalogRepositoryInterface;
|
||||
|
||||
final class CatalogService
|
||||
final class CatalogService implements CatalogServiceInterface
|
||||
{
|
||||
public function __construct(private readonly CatalogRepositoryInterface $catalog)
|
||||
{
|
||||
}
|
||||
|
||||
/** @see CatalogServiceInterface::atomic() for supported lock-order compositions. */
|
||||
public function atomic(callable $operation): mixed
|
||||
{
|
||||
return $this->catalog->atomic($operation);
|
||||
}
|
||||
|
||||
public function modelMetadata(int $modelId): ?array
|
||||
{
|
||||
return $this->catalog->modelMetadata($modelId);
|
||||
}
|
||||
|
||||
public function updateModelMetadata(int $modelId, array $expected, array $replacement): array
|
||||
{
|
||||
return $this->catalog->updateModelMetadata($modelId, $expected, $replacement);
|
||||
}
|
||||
|
||||
public function changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array
|
||||
{
|
||||
return $this->catalog->changeFileStatus($fileId, $expectedStatus, $targetStatus);
|
||||
}
|
||||
|
||||
public function file(int $fileId): ?array
|
||||
{
|
||||
return $this->catalog->file($fileId);
|
||||
}
|
||||
|
||||
public function fileByManagedPath(string $managedPath): ?array
|
||||
{
|
||||
return $this->catalog->fileByManagedPath($managedPath);
|
||||
}
|
||||
|
||||
public function registerFile(
|
||||
?int $modelId,
|
||||
string $managedPath,
|
||||
string $hash,
|
||||
int $size,
|
||||
?string $mime = null,
|
||||
string $status = 'Present'
|
||||
): int {
|
||||
return $this->catalog->recordFile($modelId, $managedPath, $hash, $size, $mime, $status);
|
||||
}
|
||||
|
||||
public function createModel(string $folderPath, string $modelName): int
|
||||
{
|
||||
return $this->catalog->atomic(fn (): int => $this->catalog->createModel($folderPath, $modelName));
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Catalog;
|
||||
|
||||
/**
|
||||
* Shared public boundary for catalog operations used by importers and consumers.
|
||||
*/
|
||||
interface CatalogServiceInterface
|
||||
{
|
||||
/**
|
||||
* Compose on the shared connection, using savepoints inside caller transactions.
|
||||
* Success never commits caller work; failure rolls back only this operation.
|
||||
* Supported: creation, then metadata, then ownership/status; or one registration,
|
||||
* then item/job progress. Acquire multiple file locks in globally ascending ID order.
|
||||
* Never enter catalog mutation holding job/item locks, acquire root/folder or
|
||||
* metadata locks after file locks, or follow registration with metadata mutation.
|
||||
* Arbitrary registration loops/batches are not supported: this is not a lock scheduler.
|
||||
* Metadata uses FOR NO KEY UPDATE, compatible with ownership's implicit FK key-share.
|
||||
*/
|
||||
public function atomic(callable $operation): mixed;
|
||||
|
||||
public function modelMetadata(int $modelId): ?array;
|
||||
|
||||
public function updateModelMetadata(int $modelId, array $expected, array $replacement): array;
|
||||
|
||||
public function changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array;
|
||||
|
||||
public function file(int $fileId): ?array;
|
||||
|
||||
public function fileByManagedPath(string $managedPath): ?array;
|
||||
|
||||
public function registerFile(
|
||||
?int $modelId,
|
||||
string $managedPath,
|
||||
string $hash,
|
||||
int $size,
|
||||
?string $mime = null,
|
||||
string $status = 'Present'
|
||||
): int;
|
||||
|
||||
public function createModel(string $folderPath, string $modelName): int;
|
||||
|
||||
/**
|
||||
* @return array{model: array, folder: array, files: array}|null
|
||||
*/
|
||||
public function aggregate(int $modelId): ?array;
|
||||
|
||||
public function assignFiles(int $modelId, array $fileIds): void;
|
||||
|
||||
public function reassignFiles(int $expectedModelId, int $targetModelId, array $fileIds): void;
|
||||
|
||||
public function detachFiles(int $expectedModelId, array $fileIds): void;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Catalog;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class ModelMetadata
|
||||
{
|
||||
public static function validateTitle(string $title): void
|
||||
{
|
||||
$length = preg_match_all('/./us', $title);
|
||||
if (
|
||||
$length === false || $length < 1 || $length > 255 || preg_match('/^\s*$/u', $title)
|
||||
|| str_contains($title, "\0")
|
||||
) {
|
||||
throw new InvalidArgumentException('Model name must contain 1 to 255 characters and not be blank or NUL.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function validateShape(array $metadata): void
|
||||
{
|
||||
if (count($metadata) !== 3 || !isset($metadata['title']) || !is_string($metadata['title'])) {
|
||||
throw new InvalidArgumentException('Metadata requires exactly title, notes and sourceURL.');
|
||||
}
|
||||
foreach (['notes', 'sourceURL'] as $field) {
|
||||
if (
|
||||
!array_key_exists($field, $metadata)
|
||||
|| ($metadata[$field] !== null && !is_string($metadata[$field]))
|
||||
) {
|
||||
throw new InvalidArgumentException('Metadata requires nullable string notes and sourceURL.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate/normalize changed fields only; expected is always a raw snapshot. */
|
||||
public static function replacement(array $expected, array $replacement): array
|
||||
{
|
||||
self::validateShape($expected);
|
||||
self::validateShape($replacement);
|
||||
$result = [];
|
||||
foreach (['title', 'notes', 'sourceURL'] as $field) {
|
||||
$value = $replacement[$field];
|
||||
if ($value !== $expected[$field]) {
|
||||
if ($field === 'title') {
|
||||
self::validateTitle($value);
|
||||
} elseif ($value === '' || $value === null) {
|
||||
$value = null;
|
||||
} elseif ($field === 'notes') {
|
||||
if (preg_match('//u', $value) !== 1 || str_contains($value, "\0")) {
|
||||
throw new InvalidArgumentException('Notes must be valid UTF-8 without NUL.');
|
||||
}
|
||||
} else {
|
||||
self::validateUrl($value);
|
||||
}
|
||||
}
|
||||
$result[$field] = $value;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function validateUrl(string $url): void
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (
|
||||
strlen($url) > 2048 || preg_match('/[^\x21-\x7e]|\\\\|%(?![0-9a-fA-F]{2})/', $url)
|
||||
|| $parts === false || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
|
||||
|| empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])
|
||||
|| filter_var($url, FILTER_VALIDATE_URL) === false
|
||||
) {
|
||||
throw new InvalidArgumentException('Source URL must be an absolute ASCII HTTP(S) URL without userinfo.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,13 @@ declare(strict_types=1);
|
||||
namespace MeshVault\Indexer;
|
||||
|
||||
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
|
||||
use MeshVault\Repository\CatalogRepositoryInterface;
|
||||
use MeshVault\Repository\ImportJobRepositoryInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Turns a source tree into a durable import job manifest.
|
||||
*
|
||||
* Grouping policy (explicit, never inferred from file contents): each file is
|
||||
* Legacy guessed grouping (not user-confirmed associations): each file is
|
||||
* assigned to the model named after its first directory component below the
|
||||
* source root; files sitting directly in the source root join a model named
|
||||
* after the source directory itself. Every model lives in a folder of the same
|
||||
@@ -24,7 +23,7 @@ final readonly class ImportService
|
||||
public function __construct(
|
||||
private FileEnumeratorInterface $enumerator,
|
||||
private ImportJobRepositoryInterface $jobs,
|
||||
private CatalogRepositoryInterface $catalog,
|
||||
private LegacyGuessedImportGroupingAdapter $groupingAdapter,
|
||||
private SpaceChecker $space,
|
||||
private string $libraryRoot,
|
||||
) {
|
||||
@@ -59,15 +58,12 @@ final readonly class ImportService
|
||||
/** @return list<array<string, mixed>> */
|
||||
private function manifest(string $sourcePath, ScanResult $scan): array
|
||||
{
|
||||
$models = [];
|
||||
$items = [];
|
||||
foreach ($scan->files as $file) {
|
||||
$group = $this->group($sourcePath, $file);
|
||||
$models[$group] ??= $this->catalog->ensureModel('/' . $group, $group);
|
||||
$items[] = [
|
||||
'source_path' => $file->sourcePath,
|
||||
'size' => $file->size,
|
||||
'model_id' => $models[$group],
|
||||
'model_id' => $this->groupingAdapter->modelFor($sourcePath, $file),
|
||||
];
|
||||
}
|
||||
// Scan failures become terminal items so the job reports them durably.
|
||||
@@ -82,13 +78,4 @@ final readonly class ImportService
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function group(string $sourcePath, ScannedFile $file): string
|
||||
{
|
||||
$parent = trim(str_replace('\\', '/', dirname($file->relativePath)), '/');
|
||||
if ($parent === '' || $parent === '.') {
|
||||
return basename(rtrim(str_replace('\\', '/', $sourcePath), '/'));
|
||||
}
|
||||
return explode('/', $parent)[0];
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ namespace MeshVault\Indexer;
|
||||
use MeshVault\Exception\PathSafetyException;
|
||||
use MeshVault\Indexer\Filesystem\PathSafety;
|
||||
use MeshVault\Indexer\SpaceChecker;
|
||||
use MeshVault\Repository\CatalogRepositoryInterface;
|
||||
use MeshVault\Catalog\CatalogServiceInterface;
|
||||
use MeshVault\Repository\ImportJobRepositoryInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
use DomainException;
|
||||
|
||||
/**
|
||||
* Serializes every managed-library mutation behind one advisory lock file and
|
||||
@@ -32,7 +33,7 @@ final readonly class ImportWorker
|
||||
/** @param null|callable(): bool $shouldStop Consulted before each item. */
|
||||
public function __construct(
|
||||
private ImportJobRepositoryInterface $jobs,
|
||||
private CatalogRepositoryInterface $catalog,
|
||||
private CatalogServiceInterface $catalog,
|
||||
private StreamingHasher $hasher,
|
||||
private VerifiedCopier $copier,
|
||||
private StlValidator $validator,
|
||||
@@ -112,20 +113,21 @@ final readonly class ImportWorker
|
||||
$published = $this->publish($plan, $destination);
|
||||
$relative = $this->relative($published);
|
||||
// Catalog and progress commit together, so a crash here simply replays.
|
||||
$this->jobs->atomic(function () use ($item, $itemId, $plan, $published, $relative): void {
|
||||
// Update managed_path to the actual published path (with collision suffix if any)
|
||||
// so import_items.managed_path always points at the published file.
|
||||
$this->jobs->updateManagedPath($itemId, $relative);
|
||||
if ($item['model_id'] !== null) {
|
||||
$this->catalog->recordFile(
|
||||
(int) $item['model_id'],
|
||||
$this->catalog->atomic(function () use ($item, $itemId, $plan, $relative): void {
|
||||
// Catalog locks precede item/job locks on the shared PDO connection.
|
||||
try {
|
||||
$this->catalog->registerFile(
|
||||
$item['model_id'] === null ? null : (int) $item['model_id'],
|
||||
$relative,
|
||||
$plan['hash'],
|
||||
$plan['size'],
|
||||
null,
|
||||
'Present'
|
||||
);
|
||||
} catch (DomainException $error) {
|
||||
throw new ImportException('CATALOG_CONFLICT', $error->getMessage(), $error);
|
||||
}
|
||||
$this->jobs->updateManagedPath($itemId, $relative);
|
||||
$this->jobs->finishItem($itemId, 'Completed');
|
||||
});
|
||||
} catch (Throwable $error) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Indexer;
|
||||
|
||||
/**
|
||||
* Stateless adapter for legacy guessed import grouping.
|
||||
* Mimics the original grouping logic: top-level directory under source root,
|
||||
* or source directory name for files in the source root.
|
||||
*/
|
||||
final class LegacyGuessedImportGroupingAdapter
|
||||
{
|
||||
public function __construct(private readonly \MeshVault\Repository\CatalogRepositoryInterface $catalog)
|
||||
{
|
||||
}
|
||||
|
||||
public function modelFor(string $sourcePath, ScannedFile $file): int
|
||||
{
|
||||
$parent = trim(dirname(str_replace('\\', '/', $file->relativePath)), '/');
|
||||
$group = $parent === '' || $parent === '.'
|
||||
? basename(rtrim(str_replace('\\', '/', $sourcePath), '/'))
|
||||
: explode('/', $parent)[0];
|
||||
// No retained IDs: rollback and later ambiguity must be visible on every call.
|
||||
return $this->catalog->ensureModel('/' . $group, $group);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ interface CatalogRepositoryInterface
|
||||
/** Legacy lookup: create on zero matches, reuse one, reject ambiguity. */
|
||||
public function ensureModel(string $folderPath, string $modelName): int;
|
||||
|
||||
/** Read a normalized stored row without acquiring locks or creating rows. */
|
||||
public function file(int $fileId): ?array;
|
||||
|
||||
public function fileByManagedPath(string $managedPath): ?array;
|
||||
|
||||
public function recordFile(
|
||||
?int $modelId,
|
||||
string $managedPath,
|
||||
@@ -35,5 +40,12 @@ interface CatalogRepositoryInterface
|
||||
*/
|
||||
public function changeFileOwner(array $fileIds, ?int $expectedOwnerId, ?int $targetOwnerId): void;
|
||||
|
||||
public function setFileStatus(int $fileId, string $status): void;
|
||||
/** Raw observation; absent models return null. */
|
||||
public function modelMetadata(int $modelId): ?array;
|
||||
|
||||
/** Full raw named-tuple CAS; validate changed content only after locked comparison. */
|
||||
public function updateModelMetadata(int $modelId, array $expected, array $replacement): array;
|
||||
|
||||
/** Locked status CAS, including no-ops; returns the normalized complete file row. */
|
||||
public function changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace MeshVault\Repository;
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
use MeshVault\Catalog\ManagedPath;
|
||||
use MeshVault\Catalog\ModelMetadata;
|
||||
use PDO;
|
||||
|
||||
final class PdoCatalogRepository extends PdoRepository implements CatalogRepositoryInterface
|
||||
@@ -28,10 +29,7 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
|
||||
|
||||
private function createOrFindModel(string $folderPath, string $modelName, bool $legacy): int
|
||||
{
|
||||
$length = preg_match_all('/./us', $modelName);
|
||||
if ($length === false || $length > 255 || preg_match('/^\s*$/u', $modelName)) {
|
||||
throw new InvalidArgumentException('Model name must contain 1 to 255 characters and not be blank.');
|
||||
}
|
||||
ModelMetadata::validateTitle($modelName);
|
||||
if ($folderPath === '') {
|
||||
throw new InvalidArgumentException('A home folder is required.');
|
||||
}
|
||||
@@ -63,6 +61,40 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
|
||||
$this->execute("UPDATE folders SET name = name WHERE path = '/'");
|
||||
}
|
||||
|
||||
public function file(int $fileId): ?array
|
||||
{
|
||||
if ($fileId <= 0) {
|
||||
throw new InvalidArgumentException('File ID must be positive.');
|
||||
}
|
||||
return $this->normalizeFile($this->row('SELECT * FROM files WHERE id = ?', [$fileId]));
|
||||
}
|
||||
|
||||
public function fileByManagedPath(string $managedPath): ?array
|
||||
{
|
||||
ManagedPath::validate($managedPath);
|
||||
$this->validateText($managedPath);
|
||||
return $this->normalizeFile($this->row('SELECT * FROM files WHERE managed_path = ?', [$managedPath]));
|
||||
}
|
||||
|
||||
private function normalizeFile(?array $file): ?array
|
||||
{
|
||||
if ($file === null) {
|
||||
return null;
|
||||
}
|
||||
foreach (['id', 'model_id', 'folder_id', 'size'] as $field) {
|
||||
$file[$field] = $file[$field] === null ? null : (int) $file[$field];
|
||||
}
|
||||
return $file;
|
||||
}
|
||||
|
||||
private function validateText(string $value, ?int $maximum = null): void
|
||||
{
|
||||
$length = preg_match_all('/./us', $value);
|
||||
if ($length === false || str_contains($value, "\0") || ($maximum !== null && $length > $maximum)) {
|
||||
throw new InvalidArgumentException('File text must be valid UTF-8 without NUL and within length limits.');
|
||||
}
|
||||
}
|
||||
|
||||
public function recordFile(
|
||||
?int $modelId,
|
||||
string $managedPath,
|
||||
@@ -73,6 +105,11 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
|
||||
): int {
|
||||
$this->validateStatus($status);
|
||||
ManagedPath::validate($managedPath);
|
||||
$this->validateText($managedPath);
|
||||
$this->validateText($hash, 64);
|
||||
if ($mime !== null) {
|
||||
$this->validateText($mime, 128);
|
||||
}
|
||||
if ($modelId !== null && $modelId <= 0) {
|
||||
throw new InvalidArgumentException('Model ID must be positive or null.');
|
||||
}
|
||||
@@ -82,18 +119,22 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
|
||||
return $this->transaction(function () use ($modelId, $managedPath, $hash, $size, $mime, $status): int {
|
||||
// Serialize catalog writers on the root row, including paths assigned to different models.
|
||||
$this->lockRoot();
|
||||
$existing = $this->row('SELECT * FROM files WHERE managed_path = ?', [$managedPath]);
|
||||
$lock = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : '';
|
||||
$existing = $this->row('SELECT * FROM files WHERE managed_path = ?' . $lock, [$managedPath]);
|
||||
if ($existing !== null) {
|
||||
$owner = $existing['model_id'] === null ? null : (int) $existing['model_id'];
|
||||
if (
|
||||
$owner !== $modelId || $existing['hash'] !== $hash
|
||||
|| (int) $existing['size'] !== $size
|
||||
|| $existing['size'] === null || (int) $existing['size'] !== $size
|
||||
) {
|
||||
throw new DomainException('Managed path already records a different file.');
|
||||
}
|
||||
return (int) $existing['id'];
|
||||
}
|
||||
$folder = $this->ensureFolder(dirname(str_replace('\\', '/', $managedPath)));
|
||||
if ($modelId !== null && $this->row('SELECT id FROM models WHERE id = ?', [$modelId]) === null) {
|
||||
throw new DomainException('Unknown model.');
|
||||
}
|
||||
$folder = $this->ensureFolder(dirname($managedPath));
|
||||
$file = $this->row(
|
||||
'INSERT INTO files (model_id, folder_id, name, managed_path, hash, size, mime_type, status)'
|
||||
. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id',
|
||||
@@ -164,17 +205,89 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
|
||||
});
|
||||
}
|
||||
|
||||
public function setFileStatus(int $fileId, string $status): void
|
||||
public function modelMetadata(int $modelId): ?array
|
||||
{
|
||||
$this->validateStatus($status);
|
||||
if (
|
||||
$this->validateModelId($modelId);
|
||||
return $this->readMetadata($modelId);
|
||||
}
|
||||
|
||||
private function validateModelId(int $modelId): void
|
||||
{
|
||||
if ($modelId <= 0) {
|
||||
throw new InvalidArgumentException('Model ID must be positive.');
|
||||
}
|
||||
}
|
||||
|
||||
private function readMetadata(int $modelId, string $lock = ''): ?array
|
||||
{
|
||||
$row = $this->row('SELECT name, notes, source_url FROM models WHERE id = ?' . $lock, [$modelId]);
|
||||
return $row === null ? null : [
|
||||
'title' => $row['name'], 'notes' => $row['notes'], 'sourceURL' => $row['source_url'],
|
||||
];
|
||||
}
|
||||
|
||||
public function updateModelMetadata(int $modelId, array $expected, array $replacement): array
|
||||
{
|
||||
$this->validateModelId($modelId);
|
||||
ModelMetadata::validateShape($expected);
|
||||
ModelMetadata::validateShape($replacement);
|
||||
return $this->transaction(function () use ($modelId, $expected, $replacement): array {
|
||||
// Compatible with ownership's implicit FK KEY SHARE; never strengthen to FOR UPDATE.
|
||||
$lock = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR NO KEY UPDATE' : '';
|
||||
$current = $this->readMetadata($modelId, $lock);
|
||||
if ($current === null) {
|
||||
throw new DomainException('Unknown model.');
|
||||
}
|
||||
foreach ($current as $field => $value) {
|
||||
if ($expected[$field] !== $value) {
|
||||
throw new DomainException('Model metadata has changed.');
|
||||
}
|
||||
}
|
||||
$next = ModelMetadata::replacement($expected, $replacement);
|
||||
$columns = [];
|
||||
$values = [];
|
||||
foreach (['title' => 'name', 'notes' => 'notes', 'sourceURL' => 'source_url'] as $field => $column) {
|
||||
if ($next[$field] !== $current[$field]) {
|
||||
$columns[] = $column . ' = ?';
|
||||
$values[] = $next[$field];
|
||||
}
|
||||
}
|
||||
if ($columns !== []) {
|
||||
$values[] = $modelId;
|
||||
$this->execute(
|
||||
'UPDATE models SET ' . implode(', ', $columns) . ', updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
$values
|
||||
);
|
||||
}
|
||||
return $next;
|
||||
});
|
||||
}
|
||||
|
||||
public function changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array
|
||||
{
|
||||
if ($fileId <= 0) {
|
||||
throw new InvalidArgumentException('File ID must be positive.');
|
||||
}
|
||||
$this->validateStatus($expectedStatus);
|
||||
$this->validateStatus($targetStatus);
|
||||
return $this->transaction(function () use ($fileId, $expectedStatus, $targetStatus): array {
|
||||
$lock = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : '';
|
||||
$file = $this->normalizeFile($this->row('SELECT * FROM files WHERE id = ?' . $lock, [$fileId]));
|
||||
if ($file === null) {
|
||||
throw new DomainException('Unknown file.');
|
||||
}
|
||||
if ($file['status'] !== $expectedStatus) {
|
||||
throw new DomainException('File status has changed.');
|
||||
}
|
||||
if ($expectedStatus === $targetStatus) {
|
||||
return $file;
|
||||
}
|
||||
$this->execute(
|
||||
'UPDATE files SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[$status, $fileId]
|
||||
)->rowCount() === 0
|
||||
) {
|
||||
throw new DomainException('Unknown file.');
|
||||
}
|
||||
[$targetStatus, $fileId]
|
||||
);
|
||||
return $this->file($fileId);
|
||||
});
|
||||
}
|
||||
|
||||
private function validateStatus(string $status): void
|
||||
|
||||
Reference in new issue
Block a user