feat: establish catalog identity and atomic file ownership
This commit is contained in:
1 parent
390a7a557a
commit
6b9fb54b4f
16 files changed
+1380
-22
No files matched your search
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use MeshVault\Catalog\CatalogService;
|
||||||
use MeshVault\Config\AppConfig;
|
use MeshVault\Config\AppConfig;
|
||||||
use MeshVault\Console\ConsoleServices;
|
use MeshVault\Console\ConsoleServices;
|
||||||
use MeshVault\Console\ContainerConsoleServices;
|
use MeshVault\Console\ContainerConsoleServices;
|
||||||
@@ -48,6 +49,8 @@ return static fn (AppConfig $config): array => [
|
|||||||
// Repositories share one PDO connection so the job repository's atomic() is a real transaction.
|
// Repositories share one PDO connection so the job repository's atomic() is a real transaction.
|
||||||
CatalogRepositoryInterface::class => static fn (ContainerInterface $c): CatalogRepositoryInterface
|
CatalogRepositoryInterface::class => static fn (ContainerInterface $c): CatalogRepositoryInterface
|
||||||
=> new PdoCatalogRepository($c->get(PDO::class)),
|
=> new PdoCatalogRepository($c->get(PDO::class)),
|
||||||
|
CatalogService::class => static fn (ContainerInterface $c): CatalogService
|
||||||
|
=> new CatalogService($c->get(CatalogRepositoryInterface::class)),
|
||||||
ImportJobRepositoryInterface::class => static fn (ContainerInterface $c): ImportJobRepositoryInterface
|
ImportJobRepositoryInterface::class => static fn (ContainerInterface $c): ImportJobRepositoryInterface
|
||||||
=> new PdoImportJobRepository($c->get(PDO::class)),
|
=> new PdoImportJobRepository($c->get(PDO::class)),
|
||||||
ImportService::class => static fn (ContainerInterface $c): ImportService => new ImportService(
|
ImportService::class => static fn (ContainerInterface $c): ImportService => new ImportService(
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use MeshVault\Catalog\ManagedPath;
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
/** Writers must be stopped for both directions, including the read-only preflight. */
|
||||||
|
final class CatalogIdentity extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$problems = [];
|
||||||
|
foreach ($this->fetchAll('SELECT id, managed_path FROM files ORDER BY id') as $file) {
|
||||||
|
try {
|
||||||
|
ManagedPath::validate($file['managed_path']);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
$problems[] = 'noncanonical managed path on file ID ' . $file['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$duplicates = $this->fetchAll('SELECT f.id FROM files f INNER JOIN
|
||||||
|
(SELECT managed_path FROM files GROUP BY managed_path HAVING COUNT(*) > 1) d
|
||||||
|
ON f.managed_path = d.managed_path ORDER BY f.id');
|
||||||
|
if ($duplicates !== []) {
|
||||||
|
$problems[] = 'duplicate managed paths on file IDs ' . implode(', ', array_column($duplicates, 'id'));
|
||||||
|
}
|
||||||
|
$this->reject(
|
||||||
|
$problems,
|
||||||
|
'Resolve paths and ownership explicitly; do not merge or delete records automatically.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->table('files')
|
||||||
|
->dropForeignKey('model_id')
|
||||||
|
->changeColumn('model_id', 'integer', ['null' => true, 'signed' => false])
|
||||||
|
->addForeignKey('model_id', 'models', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION'])
|
||||||
|
->removeIndexByName('idx_files_model_path')
|
||||||
|
->addIndex(['managed_path'], ['unique' => true, 'name' => 'idx_files_managed_path'])
|
||||||
|
->addIndex(['model_id'], ['name' => 'idx_files_model_id'])
|
||||||
|
->update();
|
||||||
|
$this->table('models')
|
||||||
|
->removeIndexByName('idx_models_folder_name')
|
||||||
|
->addIndex(['folder_id', 'name'], ['name' => 'idx_models_folder_name'])
|
||||||
|
->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$problems = [];
|
||||||
|
$unassigned = $this->fetchAll('SELECT id FROM files WHERE model_id IS NULL ORDER BY id');
|
||||||
|
if ($unassigned !== []) {
|
||||||
|
$problems[] = 'unassigned file IDs ' . implode(', ', array_column($unassigned, 'id'));
|
||||||
|
}
|
||||||
|
$duplicates = $this->fetchAll('SELECT m.id FROM models m INNER JOIN
|
||||||
|
(SELECT folder_id, name FROM models GROUP BY folder_id, name HAVING COUNT(*) > 1) d
|
||||||
|
ON m.folder_id = d.folder_id AND m.name = d.name ORDER BY m.id');
|
||||||
|
if ($duplicates !== []) {
|
||||||
|
$problems[] = 'duplicate same-folder titles on model IDs ' . implode(', ', array_column($duplicates, 'id'));
|
||||||
|
}
|
||||||
|
$this->reject(
|
||||||
|
$problems,
|
||||||
|
'Resolve by explicitly assigning files and disambiguating model titles before retrying.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->table('files')
|
||||||
|
->dropForeignKey('model_id')
|
||||||
|
->changeColumn('model_id', 'integer', ['null' => false, 'signed' => false])
|
||||||
|
->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
|
||||||
|
->removeIndexByName('idx_files_managed_path')
|
||||||
|
->removeIndexByName('idx_files_model_id')
|
||||||
|
->addIndex(['model_id', 'managed_path'], ['unique' => true, 'name' => 'idx_files_model_path'])
|
||||||
|
->update();
|
||||||
|
$this->table('models')
|
||||||
|
->removeIndexByName('idx_models_folder_name')
|
||||||
|
->addIndex(['folder_id', 'name'], ['unique' => true, 'name' => 'idx_models_folder_name'])
|
||||||
|
->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reject(array $problems, string $guidance): void
|
||||||
|
{
|
||||||
|
if ($problems !== []) {
|
||||||
|
throw new RuntimeException('Catalog identity preflight: ' . implode('; ', $problems) . '. ' . $guidance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,60 @@ changes versioned and repeatable; do not introduce a competing runner here.
|
|||||||
Subsequent issues #13-18 add physical folders, logical models, distinct files,
|
Subsequent issues #13-18 add physical folders, logical models, distinct files,
|
||||||
metadata and import jobs. Application added_at timestamps are separate from file
|
metadata and import jobs. Application added_at timestamps are separate from file
|
||||||
mtime. Migrations must never implicitly delete underlying library files.
|
mtime. Migrations must never implicitly delete underlying library files.
|
||||||
|
|
||||||
|
## Catalog identity — 20261012120001
|
||||||
|
|
||||||
|
Back up the database and **stop all writers** (web requests, import workers and
|
||||||
|
other database clients) before either upgrade or downgrade. Keep them stopped
|
||||||
|
through preflight and DDL. Preflight is not a concurrency lock.
|
||||||
|
|
||||||
|
Upgrade rejects duplicate managed paths and noncanonical paths, even without a
|
||||||
|
collision, reporting offending file IDs. It makes file ownership nullable with
|
||||||
|
RESTRICT on model deletion, replaces idx_files_model_path with unique
|
||||||
|
idx_files_managed_path and nonunique idx_files_model_id, and makes
|
||||||
|
idx_models_folder_name nonunique. Explicitly detach files before model deletion.
|
||||||
|
Rows, IDs, timestamps, dependent metadata and unrelated indexes/FKs are preserved.
|
||||||
|
|
||||||
|
Managed paths are lexical slash-separated relative names, preserved verbatim.
|
||||||
|
Empty/dot/dot-dot segments, backslashes, NUL, absolute paths and URI/drive prefixes
|
||||||
|
are rejected. Windows additionally rejects colons and trailing dots/spaces in
|
||||||
|
segments. Case and Unicode are not normalized. This is not filesystem case
|
||||||
|
comparison or symlink authorization; PathSafety still governs I/O. Migration
|
||||||
|
validation performs no filesystem access.
|
||||||
|
|
||||||
|
Investigate reported IDs and explicitly resolve catalog paths and ownership
|
||||||
|
against the library before retrying. No automatic merging, renaming, reassignment
|
||||||
|
or deletion repairs conflicts.
|
||||||
|
|
||||||
|
Downgrade to 20261011120006 rejects unassigned file IDs and duplicate same-folder
|
||||||
|
model titles before DDL. Explicitly assign files and disambiguate titles first.
|
||||||
|
Only then are NOT NULL, per-model path uniqueness, unique folder/title and the
|
||||||
|
old CASCADE model FK restored. Preflight failure leaves schema, data and migration
|
||||||
|
version unchanged.
|
||||||
|
|
||||||
|
Tasks 1–3 restore repository/importer compatibility and add the bounded catalog
|
||||||
|
service: explicit same-title identities, ambiguity-safe legacy lookup, nullable
|
||||||
|
registration and expected-owner associations using savepoint-backed transactions.
|
||||||
|
Association changes update only model_id, not physical files or home folders.
|
||||||
|
|
||||||
|
Task 4 verified the full suite on 2026-10-13:
|
||||||
|
|
||||||
|
- Windows PHP 8.3.35: foundation passed; PHPUnit 377 tests / 2032 assertions /
|
||||||
|
28 skips; standard lint passed (Composer script commands invoked directly).
|
||||||
|
- Fresh isolated PostgreSQL 17 / PHP 8.4.26 via `sh bin/test-postgres.sh`: Composer
|
||||||
|
tests 377 / 2160 / 7 skips and lint passed. Log on the Docker host:
|
||||||
|
`keith@192.168.2.23:/tmp/meshvault-catalog-tests-Ov96uNzU/pg-run-3.log`.
|
||||||
|
- The suite runs production migrations in generated schemas, including populated
|
||||||
|
down/up, dependent metadata, direct constraints, index preservation and unchanged
|
||||||
|
preflight-rejection snapshots. This does not replace an operator backup/restore
|
||||||
|
drill or deployment migration smoke acceptance.
|
||||||
|
- First PG run exposed four SQLite-trigger syntax errors in rollback tests;
|
||||||
|
dialect-specific test triggers now prove rollback after an actual earlier write.
|
||||||
|
Six new two-session cases verify 55P03 contention, stale/no-op behavior, ascending
|
||||||
|
execution parameters, nested rollback/lock release and root creation serialization.
|
||||||
|
They passed existing locking code; no induced deadlock or mutation-red claim.
|
||||||
|
|
||||||
|
No development database or historical workspace was touched. Independent full-slice
|
||||||
|
review approved this bounded implementation; #28 is not complete. Importer confirmation/recovery, broader
|
||||||
|
metadata/lifecycle contracts and unresolved M2/M3 acceptance remain open. See
|
||||||
|
`docs/project-status.md` for the evidence and limitations.
|
||||||
+39
-2
@@ -4,7 +4,44 @@ Baseline: `c1ef2ee`. This review compares the live Gitea acceptance criteria wit
|
|||||||
source, tests, and recorded runtime evidence. Implementation is not milestone
|
source, tests, and recorded runtime evidence. Implementation is not milestone
|
||||||
acceptance. The earlier assessment understated remaining M2/M3 work.
|
acceptance. The earlier assessment understated remaining M2/M3 work.
|
||||||
|
|
||||||
## Evidence
|
## Catalog-core Task 4 evidence — 2026-10-13
|
||||||
|
|
||||||
|
Tasks 1–3 were approved at handoff (371 tests / 2032 assertions / 22 skips,
|
||||||
|
local lint clean). Task 4 adds six PostgreSQL cases using independent sessions,
|
||||||
|
generated schemas, 250ms lock timeouts and 5s statement timeouts. Execution-time
|
||||||
|
parameter recording establishes ascending file locks for reversed input;
|
||||||
|
FOR SHARE contention establishes same-owner no-op locking. Tests also establish
|
||||||
|
stale-owner rejection, rollback/retry, nested transaction survival, lower-lock
|
||||||
|
release after savepoint rollback, and explicit/legacy root creation contention
|
||||||
|
including disjoint folders. No deadlock was induced and no automatic retry added.
|
||||||
|
|
||||||
|
- Fresh Windows PHP 8.3.35: foundation checks pass; full PHPUnit **377 tests,
|
||||||
|
2032 assertions, 28 skips**, exit 0; standard PHPCS lint exit 0. These are the
|
||||||
|
Composer script commands invoked directly with the required PHP executable.
|
||||||
|
- Fresh isolated Docker PostgreSQL 17 / PHP 8.4.26: `sh bin/test-postgres.sh`
|
||||||
|
ran full Composer tests and lint successfully: **377 tests, 2160 assertions,
|
||||||
|
7 skips**. Final log:
|
||||||
|
`keith@192.168.2.23:/tmp/meshvault-catalog-tests-Ov96uNzU/pg-run-3.log`.
|
||||||
|
- Initial PG run (`pg-run-1.log` in the same directory): 377 tests / 2140
|
||||||
|
assertions / 4 errors / 7 skips. All four errors were SQLite-only trigger DDL
|
||||||
|
in the later-update rollback fixture. A PostgreSQL trigger function now tests
|
||||||
|
the same earlier-write condition; `pg-run-2.log` first confirmed the fix.
|
||||||
|
New concurrency cases passed against the approved implementation; no production
|
||||||
|
locking gap or new-case red/mutation run is claimed.
|
||||||
|
- Transfer included current uncommitted source, excluding vendor, node_modules,
|
||||||
|
.git, .superpowers, environment files, secrets and key files. Dockerfile.test
|
||||||
|
installs dependencies. Dedicated disposable containers/network used no host DB
|
||||||
|
ports or persistent volumes; test containers were gone after final cleanup.
|
||||||
|
Neither `/tmp/MeshVault`, the historical PG workspace nor development data was
|
||||||
|
modified. SSH calls exceeded the tool's 30s wait; subsequent log reads verified
|
||||||
|
completion and the script's tests-and-lint success marker.
|
||||||
|
|
||||||
|
Independent whole-slice review approved the bounded implementation with no blocking
|
||||||
|
findings, reran local tests/lint and checked the PostgreSQL log and matching source
|
||||||
|
hashes. The user authorized local integration into main, not a push. No issue closure
|
||||||
|
is implied; broader #28 metadata/lifecycle contracts and the M2/M3 gaps remain open.
|
||||||
|
|
||||||
|
## Historical evidence
|
||||||
|
|
||||||
- Fresh Windows run: `composer test` — 291 tests, 1801 assertions, 22 skips; exit 0.
|
- Fresh Windows run: `composer test` — 291 tests, 1801 assertions, 22 skips; exit 0.
|
||||||
`composer lint` — exit 0. Expected HTTP failure-path logs are not test failures.
|
`composer lint` — exit 0. Expected HTTP failure-path logs are not test failures.
|
||||||
@@ -43,7 +80,7 @@ changes; issue states were rechecked through the live API afterward.
|
|||||||
| #25 | Queue/worker/locking exist; confirmed grouping and full replay/retry guarantees remain incomplete. |
|
| #25 | Queue/worker/locking exist; confirmed grouping and full replay/retry guarantees remain incomplete. |
|
||||||
| #26 | Repository progress exists; caller-facing polling, full state distinctions and worker handling of empty jobs remain incomplete. |
|
| #26 | Repository progress exists; caller-facing polling, full state distinctions and worker handling of empty jobs remain incomplete. |
|
||||||
| #27 | Failures persist; supported retry with attempt history and persisted-message redaction remain incomplete. |
|
| #27 | Failures persist; supported retry with attempt history and persisted-message redaction remain incomplete. |
|
||||||
| #28 | Importer-facing repository exists; explicit reassignment, catalog-owned transaction contract and broader metadata/lifecycle contracts remain incomplete. |
|
| #28 | Bounded identity, explicit expected-owner reassignment and catalog-owned savepoint transactions are implemented with local/PG evidence above; whole-slice review approved, but broader metadata/lifecycle contracts remain open. Not complete. |
|
||||||
|
|
||||||
## Important implementation findings
|
## Important implementation findings
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Catalog Core Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Deliver the bounded #28 foundation: explicit model identity and atomic expected-owner file associations without changing physical storage.
|
||||||
|
|
||||||
|
**Architecture:** Extend the existing catalog repository and expose its savepoint-backed transaction boundary to a thin catalog service. New Phinx migrations enforce identity constraints; PostgreSQL row locks protect association decisions. SQLite remains the fast test backend.
|
||||||
|
|
||||||
|
**Tech Stack:** Existing PHP/PDO, Phinx, Slim/PHP-DI and PHPUnit; no new dependencies.
|
||||||
|
|
||||||
|
**Spec:** C:\Users\ksolo\Projects\3D Printing Stuff\MeshVault\docs\superpowers\specs\2026-10-12-catalog-core-design.md
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- No new framework or runtime dependency.
|
||||||
|
- Add new migrations, never rewrite applied migrations.
|
||||||
|
- Preserve file IDs, bytes, paths and timestamps when associations change.
|
||||||
|
- Explicit null means unassigned, not no precondition.
|
||||||
|
- Use RESTRICT on model deletion and explicit detach.
|
||||||
|
- No browse UI, moves, physical deletion, downloads, duplicate decisions, retry engine or import crash-recovery fix in this first slice.
|
||||||
|
- Do not silently relabel unresolved M2/M3 acceptance as complete.
|
||||||
|
- Commit checkpoints require authorization; do not push automatically.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
- Migration/downgrade preflight must leave schema and data intact on failure (Task 1).
|
||||||
|
- Legacy ensureModel races after uniqueness removal require creation serialization (Tasks 2/4).
|
||||||
|
- Null ownership must never become zero or a wildcard (Tasks 2/3).
|
||||||
|
- Savepoint failures must preserve caller transactions and unrelated writes (Tasks 3/4).
|
||||||
|
- Reversed input and same-owner no-ops must not bypass locking (Task 4).
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
All paths below resolve against **C:\Users\ksolo\Projects\3D Printing Stuff\MeshVault**; use absolute paths when executing.
|
||||||
|
|
||||||
|
- Create `db/migrations/20261012120001_catalog_identity.php`: explicit up/down and safe preflights.
|
||||||
|
- Create `src/Catalog/ManagedPath.php`: lexical canonical-path validation shared by migration and repository.
|
||||||
|
- Modify `src/Repository/CatalogRepositoryInterface.php` and `src/Repository/PdoCatalogRepository.php`: identity, nullable registration, transaction boundary and associations.
|
||||||
|
- Reuse `src/Repository/PdoRepository.php`; modify only if failing transaction tests demonstrate a need.
|
||||||
|
- Create `src/Catalog/CatalogService.php`; register in `config/container.php` using the existing shared repository/PDO.
|
||||||
|
- Extend `tests/Integration/RepositoryDatabase.php` for isolated migration targets and rollback.
|
||||||
|
- Create `tests/Unit/Catalog/ManagedPathTest.php`, `tests/Integration/CatalogMigrationTest.php`, `tests/Integration/CatalogCoreTest.php`, `tests/Integration/PostgresCatalogTest.php`.
|
||||||
|
- Retain RepositoryTest and ImportPipelineTest as compatibility gates; update interface doubles wherever found.
|
||||||
|
- Update `db/migrations/README.md` and `docs/project-status.md` with observed scope/results.
|
||||||
|
|
||||||
|
## Task 1 — Safe identity migration
|
||||||
|
|
||||||
|
**Interfaces:** `ManagedPath::validate(string $path): string` returns unchanged input or throws InvalidArgumentException. Extend `RepositoryDatabase::migrate(string $path, ?int $target = null): PDO`; add `migrateTo(string $path, int $target): void` and `rollbackTo(string $path, int $target): void` using Phinx and the registered isolated connection/configuration.
|
||||||
|
|
||||||
|
**Path contract:** Nonempty slash-separated relative names; reject backslashes, NUL, URI syntax, leading slash/drive prefixes, empty segments, dot and dot-dot segments. Preserve case and Unicode spelling. Apply existing Win32 trailing-dot/space and colon restrictions on Windows. This is lexical identity, not symlink authorization or filesystem case equivalence; PathSafety still governs I/O. No filesystem access in migration validation.
|
||||||
|
|
||||||
|
- [ ] Write valid/invalid path table tests and migration tests starting at 20261011120006 with populated models, files and dependent metadata.
|
||||||
|
- [ ] Test preflight failure for duplicate managed paths across models and noncanonical paths, even without collision. Test down failure for unassigned files and duplicate same-folder titles. Compare rows, indexes/FKs and migration version before/after failure.
|
||||||
|
- [ ] Run `php vendor/phpunit/phpunit/phpunit --filter 'ManagedPathTest|CatalogMigrationTest'`; require failures for missing behavior, not harness errors.
|
||||||
|
- [ ] Implement validator and isolated migration helpers. Implement up/down preflights before DDL; require stopped writers during migration, documented in migration README. Report offending IDs and remediation guidance without repairing data.
|
||||||
|
- [ ] Up: nullable files.model_id with RESTRICT; replace idx_files_model_path with unique idx_files_managed_path and nonunique idx_files_model_id; make idx_models_folder_name nonunique. Down restores original constraints only after preflight. Preserve other indexes/FKs/columns across SQLite rebuilds.
|
||||||
|
- [ ] Test direct SQL constraints, including unique paths with null owners, invalid model FK, deletion restriction and detached file survival. Verify IDs, timestamps, dependent rows and subsequent generated IDs survive clean down/up.
|
||||||
|
- [ ] Run targeted SQLite tests and isolated PostgreSQL tests, then `composer lint`; review checkpoint before any authorized commit.
|
||||||
|
|
||||||
|
## Task 2 — Explicit identity and importer compatibility
|
||||||
|
|
||||||
|
**Interfaces:** Add `atomic(callable $operation): mixed` and `createModel(string $folderPath, string $modelName): int` to CatalogRepositoryInterface. Make recordFile's first parameter `?int $modelId`; retain other parameters/defaults. Preserve aggregate's model/folder/files array and null-on-missing behavior.
|
||||||
|
|
||||||
|
- [ ] Write CatalogCoreTest cases for empty models, independent same-title models and required home folder. Legacy ensureModel creates on zero matches, reuses one and throws DomainException on multiple matches. Reject blank titles and titles exceeding 255 characters without trimming stored titles or adding an extension dependency.
|
||||||
|
- [ ] Test nullable registration, canonical path validation, strict nullable ownership comparisons, same owner/hash/size idempotence and conflicting path rejection. Preserve existing status/hash behavior.
|
||||||
|
- [ ] Run `php vendor/phpunit/phpunit/phpunit --filter CatalogCoreTest`; confirm intended failures.
|
||||||
|
- [ ] Implement atomic using the existing savepoint helper. Both creation methods acquire the existing root-folder writer lock before lookup/insert inside a transaction. Explicit creation always inserts; legacy lookup counts matches instead of using ON CONFLICT on the removed constraint. Reject empty home-folder input; retain existing slash-root/folder conventions.
|
||||||
|
- [ ] Implement nullable registration and shared path validation before writes; retain recordFile's writer lock and database-backed path identity. Never adopt another owner's record implicitly.
|
||||||
|
- [ ] Adapt interface doubles found by search; run CatalogCoreTest, RepositoryTest, ImportPipelineTest and lint. Review checkpoint before authorized commit.
|
||||||
|
|
||||||
|
## Task 3 — Atomic associations and service boundary
|
||||||
|
|
||||||
|
**Repository interface:** `changeFileOwner(array $fileIds, ?int $expectedOwnerId, ?int $targetOwnerId): void`. Require a nonempty list of distinct positive integers; non-null owner IDs must be positive. Invalid arguments throw InvalidArgumentException; missing models/files or stale ownership throw DomainException. SQL/FK/lock failures remain PDOException; no automatic retry.
|
||||||
|
|
||||||
|
**Service interface:** Constructor takes CatalogRepositoryInterface. Expose `createModel(string $folderPath, string $modelName): int`, `aggregate(int $modelId): ?array`, `assignFiles(int $modelId, array $fileIds): void` (expected null), `reassignFiles(int $expectedModelId, int $targetModelId, array $fileIds): void`, `detachFiles(int $expectedModelId, array $fileIds): void`. Use repository atomic for command orchestration, never a separate PDO/transaction manager.
|
||||||
|
|
||||||
|
- [ ] Test aggregate with two STL parts, selected image/document and files spanning folders. Reassignment leaves empty models valid; home folders stay unchanged. Compare all file fields except model_id, physical bytes, paths and mtimes across association operations.
|
||||||
|
- [ ] Test empty/duplicate/noninteger/nonpositive selections, missing expected/target model, missing file mixed with valid IDs, stale/null expected ownership and same-owner no-op.
|
||||||
|
- [ ] Test nested success leaves the caller transaction active and caller rollback undoes changes. Caught failure preserves unrelated outer writes. Use test-only database triggers to fail a later update and prove earlier updates roll back too.
|
||||||
|
- [ ] Run CatalogCoreTest; confirm intended red tests.
|
||||||
|
- [ ] Implement transactional validation: check referenced models, sort IDs numerically, acquire each file row's FOR UPDATE lock in ascending order on PostgreSQL; omit lock syntax on SQLite. Check every selected row/owner under lock before any update. Update only model_id. Same-owner no-op still locks/checks; concurrent target deletion is constrained by FK locking or causes rollback.
|
||||||
|
- [ ] Implement service and DI registration, with container resolution coverage following existing tests. Keep importer using compatible repository methods, not a fictitious confirmed-manifest workflow.
|
||||||
|
- [ ] Run CatalogCoreTest, RepositoryTest, ImportPipelineTest and lint; review checkpoint before authorized commit.
|
||||||
|
|
||||||
|
## Task 4 — PostgreSQL contention and acceptance evidence
|
||||||
|
|
||||||
|
Follow PostgresRepositoryTest: generated schema, two connections, lock_timeout 250ms, statement_timeout 5s, finally rollback/cleanup. Configured failures must error rather than skip.
|
||||||
|
|
||||||
|
- [ ] Write contest tests: A changes ownership in an uncommitted transaction; B times out with SQLSTATE 55P03 and no changes. After A commits, B's stale owner fails rather than overwrites. If A rolls back instead, B may succeed.
|
||||||
|
- [ ] Hold FOR SHARE in A; B's same-owner no-op must block/fail. Nested lock failure preserves B's caller transaction and unrelated writes.
|
||||||
|
- [ ] Verify ascending lock parameters for reversed input with a test-only PDOStatement execution recorder. Hold the higher-ID row in A; B must time out without partial updates or leaked lower-row locks after savepoint rollback. Timeout alone does not prove ordering.
|
||||||
|
- [ ] Test root-lock contention for explicit and legacy creation; after release ensureModel reuses one match or rejects ambiguity. Preserve lock/deadlock SQLSTATE; no automatic retries or claims of reproduced deadlocks.
|
||||||
|
- [ ] Run new PostgreSQL tests red, fix demonstrated gaps, then full `composer test` and `composer lint` locally. Record fresh totals/skips.
|
||||||
|
- [ ] Run `sh bin/test-postgres.sh` from a fresh isolated checkout containing these changes on the Docker host. Never touch dirty /tmp/MeshVault or development data. Record results/evidence path; inability to run is a blocker, not a pass.
|
||||||
|
- [ ] Run `git diff --check`, inspect/reread changed files and update migration guidance/status with verified results only. Broader #28 metadata/lifecycle contracts and unresolved M2/M3 acceptance remain open.
|
||||||
|
- [ ] Whole-slice review before authorized commit/push or tracker acceptance changes.
|
||||||
|
|
||||||
|
## Self-review and handoff
|
||||||
|
|
||||||
|
Coverage checked: migration/path safety (Task 1), identity/home folder/legacy compatibility (Task 2), multipart associations/nested rollback (Task 3), PostgreSQL concurrency (Task 4). Nullable-owner signatures agree. Import confirmation, removal service, UI and other non-goals remain deferred.
|
||||||
|
|
||||||
|
Execution status: implemented with subagents and approved by task-level and independent whole-slice review. Local verification: 377 tests, 2032 assertions, 28 skips; isolated PostgreSQL verification: 377 tests, 2160 assertions, 7 skips; foundation and lint passed. The checklist above preserves the original execution prescription, not a current pending-work list. PostgreSQL tests exposed and drove a trigger portability fix; the concurrency cases passed the existing implementation without a mutation-red run, and no deadlock was induced. The user authorized local integration into main; no push is authorized. Broader #28 and M2/M3 acceptance remain open.
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Catalog core foundation — proposed design
|
# Catalog core foundation — proposed design
|
||||||
|
|
||||||
Status: draft for user approval; no product implementation authorized by this document.
|
Status: domain rules and implementation plan approved by the user; bounded catalog-core implementation completed and independently reviewed. Broader #28 and M2/M3 acceptance remain open.
|
||||||
|
|
||||||
## Intent
|
## Intent
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ No browse UI, moves, physical deletion, downloads, duplicate decisions, retry en
|
|||||||
or import crash-recovery fix in this first slice. Do not silently relabel unresolved
|
or import crash-recovery fix in this first slice. Do not silently relabel unresolved
|
||||||
M2/M3 acceptance as complete. No new framework or runtime dependency.
|
M2/M3 acceptance as complete. No new framework or runtime dependency.
|
||||||
|
|
||||||
## Proposed domain rules (require approval)
|
## Approved domain rules
|
||||||
|
|
||||||
1. A physical managed file has one durable catalog identity and belongs to at most
|
1. A physical managed file has one durable catalog identity and belongs to at most
|
||||||
one logical model. Unassigned files retain their file record. Shared ownership is
|
one logical model. Unassigned files retain their file record. Shared ownership is
|
||||||
@@ -104,5 +104,7 @@ and serialized folder/model moves (#31/#32) follow as separately designed slices
|
|||||||
|
|
||||||
## Approval boundary
|
## Approval boundary
|
||||||
|
|
||||||
The ownership/unassigned-file/empty-model rules above are proposals, not existing
|
The user approved these rules as the basis for the implementation plan, including
|
||||||
user decisions. Approve or revise them before creating the implementation plan.
|
single ownership, persistent unassigned files, empty models, independent same-title
|
||||||
|
models and a required organizational home folder. The bounded plan was approved,
|
||||||
|
implemented and independently reviewed; this does not mark #28 or M2/M3 complete.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Catalog;
|
||||||
|
|
||||||
|
use MeshVault\Repository\CatalogRepositoryInterface;
|
||||||
|
|
||||||
|
final class CatalogService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CatalogRepositoryInterface $catalog)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createModel(string $folderPath, string $modelName): int
|
||||||
|
{
|
||||||
|
return $this->catalog->atomic(fn (): int => $this->catalog->createModel($folderPath, $modelName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{model: array, folder: array, files: array}|null */
|
||||||
|
public function aggregate(int $modelId): ?array
|
||||||
|
{
|
||||||
|
return $this->catalog->aggregate($modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignFiles(int $modelId, array $fileIds): void
|
||||||
|
{
|
||||||
|
$this->catalog->atomic(fn () => $this->catalog->changeFileOwner($fileIds, null, $modelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reassignFiles(int $expectedModelId, int $targetModelId, array $fileIds): void
|
||||||
|
{
|
||||||
|
$this->catalog->atomic(fn () => $this->catalog->changeFileOwner($fileIds, $expectedModelId, $targetModelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detachFiles(int $expectedModelId, array $fileIds): void
|
||||||
|
{
|
||||||
|
$this->catalog->atomic(fn () => $this->catalog->changeFileOwner($fileIds, $expectedModelId, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Catalog;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/** Lexical identity only; filesystem authorization remains PathSafety's responsibility. */
|
||||||
|
final class ManagedPath
|
||||||
|
{
|
||||||
|
public static function validate(string $path): string
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$path === '' || str_starts_with($path, '/') || str_contains($path, '\\')
|
||||||
|
|| str_contains($path, "\0") || preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $path)
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('Managed path must be a canonical relative path.');
|
||||||
|
}
|
||||||
|
foreach (explode('/', $path) as $segment) {
|
||||||
|
if (
|
||||||
|
$segment === '' || $segment === '.' || $segment === '..'
|
||||||
|
|| (PHP_OS_FAMILY === 'Windows'
|
||||||
|
&& (str_contains($segment, ':') || rtrim($segment, '. ') !== $segment))
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('Managed path contains a noncanonical segment.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,17 @@ namespace MeshVault\Repository;
|
|||||||
|
|
||||||
interface CatalogRepositoryInterface
|
interface CatalogRepositoryInterface
|
||||||
{
|
{
|
||||||
|
/** Run an operation atomically, using a savepoint inside a caller transaction. */
|
||||||
|
public function atomic(callable $operation): mixed;
|
||||||
|
|
||||||
|
/** Always create a new identity, including for an existing folder/title pair. */
|
||||||
|
public function createModel(string $folderPath, string $modelName): int;
|
||||||
|
|
||||||
|
/** Legacy lookup: create on zero matches, reuse one, reject ambiguity. */
|
||||||
public function ensureModel(string $folderPath, string $modelName): int;
|
public function ensureModel(string $folderPath, string $modelName): int;
|
||||||
|
|
||||||
public function recordFile(
|
public function recordFile(
|
||||||
int $modelId,
|
?int $modelId,
|
||||||
string $managedPath,
|
string $managedPath,
|
||||||
string $hash,
|
string $hash,
|
||||||
int $size,
|
int $size,
|
||||||
@@ -20,5 +27,13 @@ interface CatalogRepositoryInterface
|
|||||||
/** @return array{model: array, folder: array, files: array}|null */
|
/** @return array{model: array, folder: array, files: array}|null */
|
||||||
public function aggregate(int $modelId): ?array;
|
public function aggregate(int $modelId): ?array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically change a nonempty list of distinct positive file IDs.
|
||||||
|
* Null expected ownership means unassigned, never a wildcard.
|
||||||
|
* Invalid arguments throw InvalidArgumentException; missing/stale identities throw DomainException.
|
||||||
|
* SQL and lock failures propagate unchanged, without retry.
|
||||||
|
*/
|
||||||
|
public function changeFileOwner(array $fileIds, ?int $expectedOwnerId, ?int $targetOwnerId): void;
|
||||||
|
|
||||||
public function setFileStatus(int $fileId, string $status): void;
|
public function setFileStatus(int $fileId, string $status): void;
|
||||||
}
|
}
|
||||||
@@ -6,30 +6,65 @@ namespace MeshVault\Repository;
|
|||||||
|
|
||||||
use DomainException;
|
use DomainException;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use MeshVault\Catalog\ManagedPath;
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
||||||
final class PdoCatalogRepository extends PdoRepository implements CatalogRepositoryInterface
|
final class PdoCatalogRepository extends PdoRepository implements CatalogRepositoryInterface
|
||||||
{
|
{
|
||||||
|
public function atomic(callable $operation): mixed
|
||||||
|
{
|
||||||
|
return $this->transaction($operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createModel(string $folderPath, string $modelName): int
|
||||||
|
{
|
||||||
|
return $this->createOrFindModel($folderPath, $modelName, false);
|
||||||
|
}
|
||||||
|
|
||||||
public function ensureModel(string $folderPath, string $modelName): int
|
public function ensureModel(string $folderPath, string $modelName): int
|
||||||
{
|
{
|
||||||
if ($modelName === '') {
|
return $this->createOrFindModel($folderPath, $modelName, true);
|
||||||
throw new InvalidArgumentException('Model name must not be empty.');
|
|
||||||
}
|
}
|
||||||
return $this->transaction(function () use ($folderPath, $modelName): int {
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
if ($folderPath === '') {
|
||||||
|
throw new InvalidArgumentException('A home folder is required.');
|
||||||
|
}
|
||||||
|
return $this->transaction(function () use ($folderPath, $modelName, $legacy): int {
|
||||||
|
$this->lockRoot();
|
||||||
$folder = $this->ensureFolder($folderPath);
|
$folder = $this->ensureFolder($folderPath);
|
||||||
$this->execute(
|
if ($legacy) {
|
||||||
'INSERT INTO models (folder_id, name) VALUES (?, ?) ON CONFLICT (folder_id, name) DO NOTHING',
|
$matches = $this->execute(
|
||||||
|
'SELECT id FROM models WHERE folder_id = ? AND name = ? ORDER BY id LIMIT 2',
|
||||||
[$folder, $modelName]
|
[$folder, $modelName]
|
||||||
);
|
)->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if (count($matches) > 1) {
|
||||||
|
throw new DomainException('Ambiguous model title in home folder.');
|
||||||
|
}
|
||||||
|
if ($matches !== []) {
|
||||||
|
return (int) $matches[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
return (int) $this->row(
|
return (int) $this->row(
|
||||||
'SELECT id FROM models WHERE folder_id = ? AND name = ?',
|
'INSERT INTO models (folder_id, name) VALUES (?, ?) RETURNING id',
|
||||||
[$folder, $modelName]
|
[$folder, $modelName]
|
||||||
)['id'];
|
)['id'];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function lockRoot(): void
|
||||||
|
{
|
||||||
|
$this->ensureFolder('/');
|
||||||
|
$this->execute("UPDATE folders SET name = name WHERE path = '/'");
|
||||||
|
}
|
||||||
|
|
||||||
public function recordFile(
|
public function recordFile(
|
||||||
int $modelId,
|
?int $modelId,
|
||||||
string $managedPath,
|
string $managedPath,
|
||||||
string $hash,
|
string $hash,
|
||||||
int $size,
|
int $size,
|
||||||
@@ -37,17 +72,21 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
|
|||||||
string $status = 'Present'
|
string $status = 'Present'
|
||||||
): int {
|
): int {
|
||||||
$this->validateStatus($status);
|
$this->validateStatus($status);
|
||||||
if ($managedPath === '' || $hash === '' || $size < 0) {
|
ManagedPath::validate($managedPath);
|
||||||
|
if ($modelId !== null && $modelId <= 0) {
|
||||||
|
throw new InvalidArgumentException('Model ID must be positive or null.');
|
||||||
|
}
|
||||||
|
if ($hash === '' || $size < 0) {
|
||||||
throw new InvalidArgumentException('A file requires a path, hash and non-negative size.');
|
throw new InvalidArgumentException('A file requires a path, hash and non-negative size.');
|
||||||
}
|
}
|
||||||
return $this->transaction(function () use ($modelId, $managedPath, $hash, $size, $mime, $status): int {
|
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.
|
// Serialize catalog writers on the root row, including paths assigned to different models.
|
||||||
$this->ensureFolder('/');
|
$this->lockRoot();
|
||||||
$this->execute("UPDATE folders SET name = name WHERE path = '/'");
|
|
||||||
$existing = $this->row('SELECT * FROM files WHERE managed_path = ?', [$managedPath]);
|
$existing = $this->row('SELECT * FROM files WHERE managed_path = ?', [$managedPath]);
|
||||||
if ($existing !== null) {
|
if ($existing !== null) {
|
||||||
|
$owner = $existing['model_id'] === null ? null : (int) $existing['model_id'];
|
||||||
if (
|
if (
|
||||||
(int) $existing['model_id'] !== $modelId || $existing['hash'] !== $hash
|
$owner !== $modelId || $existing['hash'] !== $hash
|
||||||
|| (int) $existing['size'] !== $size
|
|| (int) $existing['size'] !== $size
|
||||||
) {
|
) {
|
||||||
throw new DomainException('Managed path already records a different file.');
|
throw new DomainException('Managed path already records a different file.');
|
||||||
@@ -80,6 +119,51 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function changeFileOwner(array $fileIds, ?int $expectedOwnerId, ?int $targetOwnerId): void
|
||||||
|
{
|
||||||
|
if ($fileIds === [] || !array_is_list($fileIds)) {
|
||||||
|
throw new InvalidArgumentException('A nonempty list of file IDs is required.');
|
||||||
|
}
|
||||||
|
$seen = [];
|
||||||
|
foreach ($fileIds as $id) {
|
||||||
|
if (!is_int($id) || $id <= 0 || isset($seen[$id])) {
|
||||||
|
throw new InvalidArgumentException('File IDs must be distinct positive integers.');
|
||||||
|
}
|
||||||
|
$seen[$id] = true;
|
||||||
|
}
|
||||||
|
foreach ([$expectedOwnerId, $targetOwnerId] as $owner) {
|
||||||
|
if ($owner !== null && $owner <= 0) {
|
||||||
|
throw new InvalidArgumentException('Model IDs must be positive or null.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($fileIds, SORT_NUMERIC);
|
||||||
|
$this->transaction(function () use ($fileIds, $expectedOwnerId, $targetOwnerId): void {
|
||||||
|
foreach ([$expectedOwnerId, $targetOwnerId] as $owner) {
|
||||||
|
if ($owner !== null && $this->row('SELECT id FROM models WHERE id = ?', [$owner]) === null) {
|
||||||
|
throw new DomainException('Unknown model.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$lock = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : '';
|
||||||
|
foreach ($fileIds as $id) {
|
||||||
|
$file = $this->row('SELECT model_id FROM files WHERE id = ?' . $lock, [$id]);
|
||||||
|
if ($file === null) {
|
||||||
|
throw new DomainException('Unknown file.');
|
||||||
|
}
|
||||||
|
$owner = $file['model_id'] === null ? null : (int) $file['model_id'];
|
||||||
|
if ($owner !== $expectedOwnerId) {
|
||||||
|
throw new DomainException('File ownership has changed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Even a no-op must lock and validate the complete selection first.
|
||||||
|
if ($expectedOwnerId === $targetOwnerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($fileIds as $id) {
|
||||||
|
$this->execute('UPDATE files SET model_id = ? WHERE id = ?', [$targetOwnerId, $id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function setFileStatus(int $fileId, string $status): void
|
public function setFileStatus(int $fileId, string $status): void
|
||||||
{
|
{
|
||||||
$this->validateStatus($status);
|
$this->validateStatus($status);
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Integration;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use MeshVault\Repository\PdoCatalogRepository;
|
||||||
|
use PDO;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class CatalogCoreTest extends TestCase
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
private PdoCatalogRepository $catalog;
|
||||||
|
private string $database;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-core-');
|
||||||
|
$this->pdo = RepositoryDatabase::migrate($this->database);
|
||||||
|
$this->catalog = new PdoCatalogRepository($this->pdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
unset($this->catalog, $this->pdo);
|
||||||
|
gc_collect_cycles();
|
||||||
|
RepositoryDatabase::cleanup($this->database);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExplicitIdentityAndLegacyCountedLookup(): void
|
||||||
|
{
|
||||||
|
$first = $this->catalog->ensureModel('/parts', ' Plane ');
|
||||||
|
self::assertSame($first, $this->catalog->ensureModel('/parts', ' Plane '));
|
||||||
|
$second = $this->catalog->createModel('/parts', ' Plane ');
|
||||||
|
self::assertNotSame($first, $second);
|
||||||
|
foreach ([$first, $second] as $id) {
|
||||||
|
$aggregate = $this->catalog->aggregate($id);
|
||||||
|
self::assertSame(' Plane ', $aggregate['model']['name']);
|
||||||
|
self::assertSame('/parts', $aggregate['folder']['path']);
|
||||||
|
self::assertSame([], $aggregate['files']);
|
||||||
|
}
|
||||||
|
self::assertNull($this->catalog->aggregate(999999));
|
||||||
|
try {
|
||||||
|
$this->catalog->ensureModel('/parts', ' Plane ');
|
||||||
|
self::fail('Ambiguous legacy lookup must fail');
|
||||||
|
} catch (DomainException) {
|
||||||
|
self::assertSame(2, $this->countRows('models'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('invalidModels')]
|
||||||
|
public function testInvalidModelInputsWriteNothing(string $folder, string $title): void
|
||||||
|
{
|
||||||
|
foreach (['createModel', 'ensureModel'] as $method) {
|
||||||
|
try {
|
||||||
|
$this->catalog->$method($folder, $title);
|
||||||
|
self::fail('Invalid model must fail');
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
self::assertSame(0, $this->countRows('models'));
|
||||||
|
self::assertSame(0, $this->countRows('folders'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidModels(): array
|
||||||
|
{
|
||||||
|
return [['', 'Title'], ['/', ''], ['/', " \t\n"], ['/', str_repeat('a', 256)],
|
||||||
|
['/', str_repeat('é', 256)], ['../escape', 'Title']];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnicodeTitleLimitCountsCharactersAndRootIsValid(): void
|
||||||
|
{
|
||||||
|
foreach (['createModel', 'ensureModel'] as $method) {
|
||||||
|
$id = $this->catalog->$method('/', str_repeat('é', 255));
|
||||||
|
self::assertSame(str_repeat('é', 255), $this->catalog->aggregate($id)['model']['name']);
|
||||||
|
self::assertSame('/', $this->catalog->aggregate($id)['folder']['path']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNullableRegistrationIsIdempotentWithoutChangingMetadata(): void
|
||||||
|
{
|
||||||
|
$id = $this->catalog->recordFile(null, 'parts/é.stl', 'hash', 12, 'model/stl', 'Missing');
|
||||||
|
$before = $this->pdo->query('SELECT * FROM files')->fetch(PDO::FETCH_ASSOC);
|
||||||
|
self::assertNull($before['model_id']);
|
||||||
|
self::assertSame($id, $this->catalog->recordFile(null, 'parts/é.stl', 'hash', 12));
|
||||||
|
self::assertSame($before, $this->pdo->query('SELECT * FROM files')->fetch(PDO::FETCH_ASSOC));
|
||||||
|
$model = $this->catalog->createModel('/elsewhere', 'Title');
|
||||||
|
$owned = $this->catalog->recordFile($model, 'other.stl', 'hash', 12);
|
||||||
|
self::assertSame($owned, $this->catalog->recordFile($model, 'other.stl', 'hash', 12));
|
||||||
|
self::assertSame('/elsewhere', $this->catalog->aggregate($model)['folder']['path']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('registrationConflicts')]
|
||||||
|
public function testRegistrationNeverAdoptsOrOverwrites(string $conflict): void
|
||||||
|
{
|
||||||
|
$model = $this->catalog->ensureModel('/', 'Title');
|
||||||
|
$owner = $conflict === 'detach' ? $model : null;
|
||||||
|
$this->catalog->recordFile($owner, 'part.stl', 'hash', 12);
|
||||||
|
$before = $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
try {
|
||||||
|
$this->catalog->recordFile(
|
||||||
|
$conflict === 'adopt' ? $model : null,
|
||||||
|
'part.stl',
|
||||||
|
$conflict === 'hash' ? 'different' : 'hash',
|
||||||
|
$conflict === 'size' ? 13 : 12
|
||||||
|
);
|
||||||
|
self::fail('Conflicting registration must fail');
|
||||||
|
} catch (DomainException) {
|
||||||
|
self::assertSame($before, $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function registrationConflicts(): array
|
||||||
|
{
|
||||||
|
return [['adopt'], ['detach'], ['hash'], ['size']];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('invalidRegistrations')]
|
||||||
|
public function testInvalidRegistrationWritesNothing(?int $owner, string $path, string $hash, int $size): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->catalog->recordFile($owner, $path, $hash, $size);
|
||||||
|
self::fail('Invalid registration must fail');
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
self::assertSame(0, $this->countRows('files'));
|
||||||
|
self::assertSame(0, $this->countRows('folders'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidRegistrations(): array
|
||||||
|
{
|
||||||
|
$cases = [];
|
||||||
|
foreach (['', '/absolute', 'a\\b', 'a//b', 'a/./b', 'a/../b', "a\0b", 'https://host/a'] as $path) {
|
||||||
|
$cases[] = [null, $path, 'hash', 1];
|
||||||
|
}
|
||||||
|
return array_merge($cases, [[0, 'a', 'hash', 1], [-1, 'a', 'hash', 1],
|
||||||
|
[null, 'a', '', 1], [null, 'a', 'hash', -1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAtomicReturnsValuesAndPreservesCallerTransaction(): void
|
||||||
|
{
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$outer = $this->catalog->ensureModel('/', 'Outer');
|
||||||
|
$id = $this->catalog->atomic(fn () => $this->catalog->createModel('/', 'Inner'));
|
||||||
|
self::assertNotSame($outer, $id);
|
||||||
|
self::assertTrue($this->pdo->inTransaction());
|
||||||
|
try {
|
||||||
|
$this->catalog->atomic(function (): void {
|
||||||
|
$this->catalog->createModel('/rollback', 'Rollback');
|
||||||
|
$this->catalog->recordFile(null, 'rollback/file.stl', 'hash', 1);
|
||||||
|
throw new RuntimeException('abort');
|
||||||
|
});
|
||||||
|
self::fail('Expected failure');
|
||||||
|
} catch (RuntimeException $error) {
|
||||||
|
self::assertSame('abort', $error->getMessage());
|
||||||
|
}
|
||||||
|
self::assertTrue($this->pdo->inTransaction());
|
||||||
|
self::assertSame(2, $this->countRows('models'));
|
||||||
|
self::assertSame(1, $this->countRows('folders'));
|
||||||
|
self::assertSame(0, $this->countRows('files'));
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
self::assertSame(0, $this->countRows('models'));
|
||||||
|
self::assertSame('result', $this->catalog->atomic(static fn () => 'result'));
|
||||||
|
self::assertFalse($this->pdo->inTransaction());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('invalidAssociations')]
|
||||||
|
public function testInvalidAssociationArguments(array $ids, ?int $expected, ?int $target): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->catalog->changeFileOwner($ids, $expected, $target);
|
||||||
|
self::fail('Invalid association must fail');
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
self::assertSame(0, $this->countRows('files'));
|
||||||
|
self::assertFalse($this->pdo->inTransaction());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidAssociations(): array
|
||||||
|
{
|
||||||
|
return [[[], null, 1], [[1, 1], null, 1], [['1'], null, 1], [[1.0], null, 1],
|
||||||
|
[[true], null, 1], [[null], null, 1], [[0], null, 1], [[-1], null, 1],
|
||||||
|
[[1], 0, 1], [[1], -1, 1], [[1], null, 0], [[1], null, -1], [[2 => 1], null, 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('associationConflicts')]
|
||||||
|
public function testAssociationConflictsPreserveEveryRow(string $case): void
|
||||||
|
{
|
||||||
|
$first = $this->catalog->createModel('/first', 'First');
|
||||||
|
$second = $this->catalog->createModel('/second', 'Second');
|
||||||
|
$one = $this->catalog->recordFile($first, 'one.stl', 'one', 1);
|
||||||
|
$two = $this->catalog->recordFile($case === 'mixed-owner' ? $second : $first, 'two.stl', 'two', 2);
|
||||||
|
$before = $this->fileRows();
|
||||||
|
$expected = match ($case) {
|
||||||
|
'missing-expected' => 999999,
|
||||||
|
'null-expected' => null,
|
||||||
|
'stale-expected' => $second,
|
||||||
|
default => $first,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
$this->catalog->changeFileOwner(
|
||||||
|
[$one, $case === 'missing-file' ? 999999 : $two],
|
||||||
|
$expected,
|
||||||
|
$case === 'missing-target' ? 999999 : $second
|
||||||
|
);
|
||||||
|
self::fail('Conflict must fail');
|
||||||
|
} catch (DomainException) {
|
||||||
|
self::assertSame($before, $this->fileRows());
|
||||||
|
self::assertFalse($this->pdo->inTransaction());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function associationConflicts(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (string $case): array => [$case], [
|
||||||
|
'missing-expected', 'missing-target', 'missing-file', 'null-expected', 'stale-expected', 'mixed-owner',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNestedAssociationSuccessRemainsOwnedByCaller(): void
|
||||||
|
{
|
||||||
|
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
|
||||||
|
$model = $service->createModel('/', 'Target');
|
||||||
|
$file = $this->catalog->recordFile(null, 'one.stl', 'hash', 1);
|
||||||
|
$before = $this->fileRows();
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$service->assignFiles($model, [$file]);
|
||||||
|
self::assertTrue($this->pdo->inTransaction());
|
||||||
|
self::assertSame($model, (int) $this->fileRows()[0]['model_id']);
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
self::assertSame($before, $this->fileRows());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('failureTransactions')]
|
||||||
|
public function testLaterUpdateFailureRollsBackEarlierUpdates(bool $nested, bool $throughService): void
|
||||||
|
{
|
||||||
|
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
|
||||||
|
$model = $service->createModel('/', 'Target');
|
||||||
|
$one = $this->catalog->recordFile(null, 'one.stl', 'one', 1);
|
||||||
|
$two = $this->catalog->recordFile(null, 'two.stl', 'two', 2);
|
||||||
|
$before = $this->fileRows();
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') {
|
||||||
|
$this->pdo->exec('CREATE FUNCTION reject_later_update() RETURNS trigger LANGUAGE plpgsql AS $$'
|
||||||
|
. ' BEGIN IF NEW.id = ' . $two
|
||||||
|
. ' AND (SELECT model_id FROM files WHERE id = ' . $one . ') = ' . $model
|
||||||
|
. " THEN RAISE EXCEPTION 'later update rejected'; END IF; RETURN NEW; END; $$");
|
||||||
|
$this->pdo->exec('CREATE TRIGGER fail_later BEFORE UPDATE OF model_id ON files'
|
||||||
|
. ' FOR EACH ROW EXECUTE FUNCTION reject_later_update()');
|
||||||
|
} else {
|
||||||
|
$this->pdo->exec('CREATE TRIGGER fail_later BEFORE UPDATE OF model_id ON files WHEN NEW.id = ' . $two
|
||||||
|
. ' AND (SELECT model_id FROM files WHERE id = ' . $one . ') = ' . $model
|
||||||
|
. " BEGIN SELECT RAISE(ABORT, 'later update rejected'); END");
|
||||||
|
}
|
||||||
|
if ($nested) {
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$outer = $service->createModel('/outer', 'Outer');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if ($throughService) {
|
||||||
|
$service->assignFiles($model, [$two, $one]);
|
||||||
|
} else {
|
||||||
|
$this->catalog->changeFileOwner([$two, $one], null, $model);
|
||||||
|
}
|
||||||
|
self::fail('Later SQL update must fail');
|
||||||
|
} catch (\PDOException $error) {
|
||||||
|
self::assertStringContainsString('later update rejected', $error->getMessage());
|
||||||
|
}
|
||||||
|
self::assertSame($before, $this->fileRows());
|
||||||
|
self::assertSame($nested, $this->pdo->inTransaction());
|
||||||
|
if ($nested) {
|
||||||
|
self::assertNotNull($service->aggregate($outer));
|
||||||
|
$this->pdo->commit();
|
||||||
|
self::assertNotNull($service->aggregate($outer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function failureTransactions(): array
|
||||||
|
{
|
||||||
|
return [[false, false], [true, false], [false, true], [true, true]];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServiceAssociationsPreservePhysicalFilesAndMetadata(): void
|
||||||
|
{
|
||||||
|
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
|
||||||
|
$first = $service->createModel('/home', 'Multipart');
|
||||||
|
$second = $service->createModel('/other-home', 'Destination');
|
||||||
|
$root = dirname(__DIR__, 2) . '/.superpowers/catalog-core/files-' . bin2hex(random_bytes(6));
|
||||||
|
mkdir($root, 0777, true);
|
||||||
|
$paths = ['parts/one.stl', 'parts/two.stl', 'images/selected.png', 'docs/selected.pdf', 'docs/unselected.pdf'];
|
||||||
|
$ids = $physical = [];
|
||||||
|
try {
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (!is_dir(dirname($root . '/' . $path))) {
|
||||||
|
mkdir(dirname($root . '/' . $path));
|
||||||
|
}
|
||||||
|
$bytes = "fixture\0" . $path;
|
||||||
|
file_put_contents($root . '/' . $path, $bytes);
|
||||||
|
touch($root . '/' . $path, 1000000000);
|
||||||
|
$physical[$path] = [file_get_contents($root . '/' . $path), filemtime($root . '/' . $path)];
|
||||||
|
$ids[] = $this->catalog->recordFile(null, $path, hash('sha256', $bytes), strlen($bytes));
|
||||||
|
}
|
||||||
|
$before = $this->fileRows();
|
||||||
|
$selected = array_slice($ids, 0, 4);
|
||||||
|
$service->assignFiles($first, array_reverse($selected));
|
||||||
|
self::assertSame($selected, array_column($service->aggregate($first)['files'], 'id'));
|
||||||
|
$this->catalog->changeFileOwner($selected, $first, $first);
|
||||||
|
$service->reassignFiles($first, $second, $selected);
|
||||||
|
self::assertSame([], $service->aggregate($first)['files']);
|
||||||
|
self::assertSame('/home', $service->aggregate($first)['folder']['path']);
|
||||||
|
self::assertSame('/other-home', $service->aggregate($second)['folder']['path']);
|
||||||
|
self::assertSame($selected, array_column($service->aggregate($second)['files'], 'id'));
|
||||||
|
$after = $this->fileRows();
|
||||||
|
foreach ($after as $index => $row) {
|
||||||
|
self::assertSame($index < 4 ? $second : null, $row['model_id']);
|
||||||
|
$row['model_id'] = null;
|
||||||
|
self::assertSame($before[$index], $row);
|
||||||
|
}
|
||||||
|
$service->detachFiles($second, $selected);
|
||||||
|
$this->catalog->changeFileOwner($selected, null, null);
|
||||||
|
self::assertSame($before, $this->fileRows());
|
||||||
|
self::assertSame([], $service->aggregate($second)['files']);
|
||||||
|
self::assertNull($service->aggregate(999999));
|
||||||
|
foreach ($physical as $path => $snapshot) {
|
||||||
|
clearstatcache(true, $root . '/' . $path);
|
||||||
|
self::assertSame($snapshot, [
|
||||||
|
file_get_contents($root . '/' . $path), filemtime($root . '/' . $path),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (is_file($root . '/' . $path)) {
|
||||||
|
unlink($root . '/' . $path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['parts', 'images', 'docs'] as $folder) {
|
||||||
|
if (is_dir($root . '/' . $folder)) {
|
||||||
|
rmdir($root . '/' . $folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rmdir($root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testContainerResolvesServiceWithSharedRepositoryAndPdo(): void
|
||||||
|
{
|
||||||
|
$config = \MeshVault\Config\AppConfig::fromEnvironment([
|
||||||
|
'MESHVAULT_LIBRARY_DIR' => dirname(__DIR__, 2), 'MESHVAULT_DB_HOST' => 'unused',
|
||||||
|
'MESHVAULT_DB_NAME' => 'unused', 'MESHVAULT_DB_USER' => 'unused',
|
||||||
|
'MESHVAULT_DB_PASSWORD' => 'unused',
|
||||||
|
]);
|
||||||
|
$definitions = require dirname(__DIR__, 2) . '/config/container.php';
|
||||||
|
$builder = new \DI\ContainerBuilder();
|
||||||
|
$builder->useAutowiring(false);
|
||||||
|
$builder->addDefinitions($definitions($config));
|
||||||
|
$builder->addDefinitions([PDO::class => $this->pdo]);
|
||||||
|
$container = $builder->build();
|
||||||
|
self::assertTrue($container->has(\MeshVault\Catalog\CatalogService::class));
|
||||||
|
$service = $container->get(\MeshVault\Catalog\CatalogService::class);
|
||||||
|
$repository = $container->get(\MeshVault\Repository\CatalogRepositoryInterface::class);
|
||||||
|
self::assertSame($repository, (new \ReflectionObject($service))->getProperty('catalog')->getValue($service));
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$model = $service->createModel('/', 'Container');
|
||||||
|
$file = $repository->recordFile(null, 'container.stl', 'hash', 1);
|
||||||
|
$service->assignFiles($model, [$file]);
|
||||||
|
self::assertCount(1, $service->aggregate($model)['files']);
|
||||||
|
self::assertTrue($this->pdo->inTransaction());
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
self::assertNull($service->aggregate($model));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fileRows(): array
|
||||||
|
{
|
||||||
|
return $this->pdo->query('SELECT * FROM files ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countRows(string $table): int
|
||||||
|
{
|
||||||
|
return (int) $this->pdo->query('SELECT COUNT(*) FROM ' . $table)->fetchColumn();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Integration;
|
||||||
|
|
||||||
|
use ArrayObject;
|
||||||
|
use PDOStatement;
|
||||||
|
|
||||||
|
/** Records execution, including the lock attempt that throws, rather than just SQL preparation. */
|
||||||
|
final class CatalogLockStatement extends PDOStatement
|
||||||
|
{
|
||||||
|
protected function __construct(private ArrayObject $locks)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(?array $params = null): bool
|
||||||
|
{
|
||||||
|
if (preg_match('/SELECT .* FROM files WHERE id = \? FOR UPDATE$/i', $this->queryString)) {
|
||||||
|
$this->locks[] = $params;
|
||||||
|
}
|
||||||
|
return parent::execute($params);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Integration;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class CatalogMigrationTest extends TestCase
|
||||||
|
{
|
||||||
|
#[DataProvider('badPaths')]
|
||||||
|
public function testUpgradePreflightLeavesEverythingIntact(string $path, bool $duplicate): void
|
||||||
|
{
|
||||||
|
if ($duplicate) {
|
||||||
|
$this->pdo->exec("INSERT INTO files (id, model_id, folder_id, name, managed_path)
|
||||||
|
VALUES (31, 21, 10, 'other', 'parts/part.stl')");
|
||||||
|
} else {
|
||||||
|
$statement = $this->pdo->prepare('UPDATE files SET managed_path = ? WHERE id = 30');
|
||||||
|
$statement->execute([$path]);
|
||||||
|
}
|
||||||
|
$this->assertPreflightFails(false, $duplicate ? ['30', '31'] : ['30']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function badPaths(): array
|
||||||
|
{
|
||||||
|
return [['parts/part.stl', true], ['a/../part.stl', false], ['a//part.stl', false], ['C:/part.stl', false]];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('downgradeConflicts')]
|
||||||
|
public function testDowngradePreflightLeavesEverythingIntact(bool $unassigned): void
|
||||||
|
{
|
||||||
|
RepositoryDatabase::migrateTo($this->path, self::NEW);
|
||||||
|
if ($unassigned) {
|
||||||
|
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
|
||||||
|
} else {
|
||||||
|
$this->pdo->exec("UPDATE models SET name = 'First' WHERE id = 21");
|
||||||
|
}
|
||||||
|
$this->assertPreflightFails(true, $unassigned ? ['30'] : ['20', '21']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function downgradeConflicts(): array
|
||||||
|
{
|
||||||
|
return [[true], [false]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertPreflightFails(bool $down, array $ids): void
|
||||||
|
{
|
||||||
|
$before = $this->snapshot();
|
||||||
|
try {
|
||||||
|
if ($down) {
|
||||||
|
RepositoryDatabase::rollbackTo($this->path, self::OLD);
|
||||||
|
} else {
|
||||||
|
RepositoryDatabase::migrateTo($this->path, self::NEW);
|
||||||
|
}
|
||||||
|
self::fail('Expected preflight rejection');
|
||||||
|
} catch (RuntimeException $error) {
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
self::assertStringContainsString($id, $error->getMessage());
|
||||||
|
}
|
||||||
|
self::assertStringContainsString('Resolve', $error->getMessage());
|
||||||
|
}
|
||||||
|
self::assertSame($before, $this->snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rows(): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$tables = ['folders', 'models', 'files', 'tags', 'model_tags', 'thumbnails', 'import_jobs', 'import_items'];
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$rows[$table] = $this->pdo->query('SELECT * FROM ' . $table . ' ORDER BY 1')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function indexes(): array
|
||||||
|
{
|
||||||
|
$indexes = [];
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
|
||||||
|
foreach (['files', 'models'] as $table) {
|
||||||
|
foreach ($this->pdo->query('PRAGMA index_list(' . $table . ')')->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$indexes[$table][$row['name']] = (int) $row['unique'];
|
||||||
|
}
|
||||||
|
ksort($indexes[$table]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $indexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snapshot(): array
|
||||||
|
{
|
||||||
|
$snapshot = [$this->rows(), $this->pdo->query('SELECT * FROM phinxlog ORDER BY version')->fetchAll()];
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
|
||||||
|
$snapshot[] = $this->pdo->query('SELECT * FROM sqlite_master ORDER BY type, name')->fetchAll();
|
||||||
|
foreach (['files', 'models'] as $table) {
|
||||||
|
$snapshot[] = $this->pdo->query('PRAGMA foreign_key_list(' . $table . ')')->fetchAll();
|
||||||
|
$snapshot[] = $this->pdo->query('PRAGMA index_list(' . $table . ')')->fetchAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertRejected(string $sql): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->pdo->exec($sql);
|
||||||
|
self::fail('Expected database constraint rejection');
|
||||||
|
} catch (PDOException) {
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const OLD = 20261011120006;
|
||||||
|
private const NEW = 20261012120001;
|
||||||
|
private string $directory;
|
||||||
|
private string $path;
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->directory = RepositoryDatabase::directory('catalog-migration');
|
||||||
|
$this->path = $this->directory . '/catalog.sqlite';
|
||||||
|
$this->pdo = RepositoryDatabase::migrate($this->path, self::OLD);
|
||||||
|
$this->pdo->exec("INSERT INTO folders (id, name, path) VALUES (10, 'root', '/')");
|
||||||
|
$this->pdo->exec("INSERT INTO models (id, folder_id, name, notes) VALUES
|
||||||
|
(20, 10, 'First', 'keep'), (21, 10, 'Second', 'also keep')");
|
||||||
|
$this->pdo->exec("INSERT INTO files (id, model_id, folder_id, name, managed_path, hash, size)
|
||||||
|
VALUES (30, 20, 10, 'part.stl', 'parts/part.stl', 'abc', 123)");
|
||||||
|
// Explicit fixture IDs do not advance PostgreSQL sequences.
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') {
|
||||||
|
foreach (['folders' => 10, 'models' => 21, 'files' => 30] as $table => $id) {
|
||||||
|
$this->pdo->query("SELECT setval(pg_get_serial_sequence('$table', 'id'), $id, true)")->closeCursor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->pdo->exec("INSERT INTO tags (id, name) VALUES (40, 'tag')");
|
||||||
|
$this->pdo->exec('INSERT INTO model_tags (model_id, tag_id) VALUES (20, 40)');
|
||||||
|
$this->pdo->exec("INSERT INTO thumbnails (model_id, managed_path, hash, size, mime_type)
|
||||||
|
VALUES (20, 'thumb.png', 'thumbhash', 12, 'image/png')");
|
||||||
|
$this->pdo->exec("INSERT INTO import_jobs (id, source_path) VALUES (50, 'source')");
|
||||||
|
$this->pdo->exec("INSERT INTO import_items (job_id, source_path, model_id) VALUES (50, 'part.stl', 20)");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
unset($this->pdo);
|
||||||
|
RepositoryDatabase::cleanup($this->path);
|
||||||
|
RepositoryDatabase::remove($this->directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRoundTripPreservesRowsAndConstraints(): void
|
||||||
|
{
|
||||||
|
$before = $this->rows();
|
||||||
|
$oldIndexes = $this->indexes();
|
||||||
|
RepositoryDatabase::migrateTo($this->path, self::NEW);
|
||||||
|
$newIndexes = $this->indexes();
|
||||||
|
if ($newIndexes !== []) {
|
||||||
|
self::assertSame(1, $newIndexes['files']['idx_files_managed_path']);
|
||||||
|
self::assertSame(0, $newIndexes['files']['idx_files_model_id']);
|
||||||
|
self::assertSame(0, $newIndexes['models']['idx_models_folder_name']);
|
||||||
|
foreach (['idx_files_hash', 'idx_files_status', 'idx_files_folder_id'] as $index) {
|
||||||
|
self::assertSame($oldIndexes['files'][$index], $newIndexes['files'][$index]);
|
||||||
|
}
|
||||||
|
self::assertSame(
|
||||||
|
$oldIndexes['models']['idx_models_added_at'],
|
||||||
|
$newIndexes['models']['idx_models_added_at']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self::assertSame($before, $this->rows());
|
||||||
|
$this->assertRejected("INSERT INTO files (model_id, folder_id, name, managed_path)
|
||||||
|
VALUES (NULL, 10, 'duplicate', 'parts/part.stl')");
|
||||||
|
$this->assertRejected('UPDATE files SET model_id = 999 WHERE id = 30');
|
||||||
|
$this->assertRejected('DELETE FROM models WHERE id = 20');
|
||||||
|
$this->assertRejected('DELETE FROM folders WHERE id = 10');
|
||||||
|
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
|
||||||
|
$this->assertRejected("INSERT INTO files (model_id, folder_id, name, managed_path)
|
||||||
|
VALUES (NULL, 10, 'duplicate', 'parts/part.stl')");
|
||||||
|
$this->pdo->exec('UPDATE files SET model_id = 20 WHERE id = 30');
|
||||||
|
RepositoryDatabase::rollbackTo($this->path, self::OLD);
|
||||||
|
self::assertSame($oldIndexes, $this->indexes());
|
||||||
|
self::assertSame($before, $this->rows());
|
||||||
|
$this->assertRejected('UPDATE files SET model_id = NULL WHERE id = 30');
|
||||||
|
$this->assertRejected("INSERT INTO models (folder_id, name) VALUES (10, 'First')");
|
||||||
|
RepositoryDatabase::migrateTo($this->path, self::NEW);
|
||||||
|
self::assertSame($before, $this->rows());
|
||||||
|
$model = $this->pdo->query("INSERT INTO models (folder_id, name) VALUES (10, 'First') RETURNING id");
|
||||||
|
self::assertGreaterThan(21, (int) $model->fetchColumn());
|
||||||
|
$model->closeCursor();
|
||||||
|
$file = $this->pdo->query("INSERT INTO files (model_id, folder_id, name, managed_path)
|
||||||
|
VALUES (NULL, 10, 'new', 'new.stl') RETURNING id");
|
||||||
|
self::assertGreaterThan(30, (int) $file->fetchColumn());
|
||||||
|
$file->closeCursor();
|
||||||
|
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
|
||||||
|
$this->pdo->exec('DELETE FROM models WHERE id = 20');
|
||||||
|
self::assertSame(2, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
|
||||||
|
self::assertSame([], $this->pdo->query('PRAGMA foreign_key_check')->fetchAll());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Integration;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
use MeshVault\Repository\PdoCatalogRepository;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class PostgresCatalogTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $database;
|
||||||
|
private PDO $pdo;
|
||||||
|
private PDO $other;
|
||||||
|
private PdoCatalogRepository $catalog;
|
||||||
|
private PdoCatalogRepository $contender;
|
||||||
|
private \ArrayObject $locks;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
if (!RepositoryDatabase::postgresConfigured()) {
|
||||||
|
self::markTestSkipped('Set MESHVAULT_TEST_PG_DSN to run PostgreSQL catalog contention tests.');
|
||||||
|
}
|
||||||
|
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-pg-catalog-');
|
||||||
|
$this->pdo = RepositoryDatabase::migrate($this->database);
|
||||||
|
$this->other = RepositoryDatabase::connect($this->database);
|
||||||
|
foreach ([$this->pdo, $this->other] as $pdo) {
|
||||||
|
$pdo->exec("SET lock_timeout = '250ms'");
|
||||||
|
$pdo->exec("SET statement_timeout = '5s'");
|
||||||
|
}
|
||||||
|
$this->locks = new \ArrayObject();
|
||||||
|
$this->other->setAttribute(PDO::ATTR_STATEMENT_CLASS, [CatalogLockStatement::class, [$this->locks]]);
|
||||||
|
$this->catalog = new PdoCatalogRepository($this->pdo);
|
||||||
|
$this->contender = new PdoCatalogRepository($this->other);
|
||||||
|
self::assertNotSame(
|
||||||
|
$this->pdo->query('SELECT pg_backend_pid()')->fetchColumn(),
|
||||||
|
$this->other->query('SELECT pg_backend_pid()')->fetchColumn(),
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
$this->pdo->query('SELECT current_schema()')->fetchColumn(),
|
||||||
|
$this->other->query('SELECT current_schema()')->fetchColumn(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach (['pdo', 'other'] as $property) {
|
||||||
|
if (isset($this->$property) && $this->$property->inTransaction()) {
|
||||||
|
$this->$property->rollBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($this->catalog, $this->contender, $this->pdo, $this->other);
|
||||||
|
if (isset($this->database)) {
|
||||||
|
RepositoryDatabase::cleanup($this->database);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fixture(): array
|
||||||
|
{
|
||||||
|
$source = $this->catalog->createModel('/parts', 'Source');
|
||||||
|
$target = $this->catalog->createModel('/parts', 'Target');
|
||||||
|
$low = $this->catalog->recordFile($source, 'parts/low.stl', 'low', 3);
|
||||||
|
$high = $this->catalog->recordFile($source, 'parts/high.stl', 'high', 4);
|
||||||
|
return [$source, $target, $low, $high];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function timeout(callable $operation): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$operation();
|
||||||
|
self::fail('Expected a PostgreSQL lock timeout, not a successful write or no-op.');
|
||||||
|
} catch (PDOException $error) {
|
||||||
|
self::assertSame('55P03', $error->getCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function files(): array
|
||||||
|
{
|
||||||
|
return $this->other->query('SELECT * FROM files ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('transactionOutcomes')]
|
||||||
|
public function testUncommittedOwnerBlocksThenCommitRejectsStaleOrRollbackAllowsRetry(bool $commit): void
|
||||||
|
{
|
||||||
|
[$source, $target, $low, $high] = $this->fixture();
|
||||||
|
$before = $this->files();
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$this->catalog->changeFileOwner([$low, $high], $source, $target);
|
||||||
|
$this->timeout(fn () => $this->contender->changeFileOwner([$high, $low], $source, null));
|
||||||
|
self::assertFalse($this->other->inTransaction());
|
||||||
|
self::assertSame($before, $this->files());
|
||||||
|
if ($commit) {
|
||||||
|
$this->pdo->commit();
|
||||||
|
$committed = $this->files();
|
||||||
|
try {
|
||||||
|
$this->contender->changeFileOwner([$high, $low], $source, null);
|
||||||
|
self::fail('Stale expected ownership must not overwrite the winner.');
|
||||||
|
} catch (DomainException $error) {
|
||||||
|
self::assertSame('File ownership has changed.', $error->getMessage());
|
||||||
|
}
|
||||||
|
self::assertSame($committed, $this->files());
|
||||||
|
self::assertSame([$target, $target], array_column($committed, 'model_id'));
|
||||||
|
} else {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
$this->contender->changeFileOwner([$high, $low], $source, null);
|
||||||
|
self::assertSame([null, null], array_column($this->files(), 'model_id'));
|
||||||
|
}
|
||||||
|
self::assertFalse($this->other->inTransaction());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('associationTargets')]
|
||||||
|
public function testReversedLocksAndNestedFailureReleaseLowerLockWithoutPartialWrites(bool $noop): void
|
||||||
|
{
|
||||||
|
[$source, $target, $low, $high] = $this->fixture();
|
||||||
|
$before = $this->files();
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$held = $this->pdo->prepare('SELECT id FROM files WHERE id = ? FOR SHARE');
|
||||||
|
$held->execute([$high]);
|
||||||
|
$held->closeCursor();
|
||||||
|
$this->other->beginTransaction();
|
||||||
|
$this->other->exec("INSERT INTO tags (name) VALUES ('outer survives')");
|
||||||
|
$this->timeout(fn () => $this->contender->changeFileOwner(
|
||||||
|
[$high, $low],
|
||||||
|
$source,
|
||||||
|
$noop ? $source : $target,
|
||||||
|
));
|
||||||
|
// A timeout alone cannot establish order, especially when no UPDATE would occur.
|
||||||
|
self::assertSame([[$low], [$high]], $this->locks->getArrayCopy());
|
||||||
|
self::assertTrue($this->other->inTransaction());
|
||||||
|
self::assertSame($before, $this->files());
|
||||||
|
self::assertSame('outer survives', $this->other->query('SELECT name FROM tags')->fetchColumn());
|
||||||
|
// NOWAIT proves the lower lock was released by B's savepoint rollback, not B's transaction end.
|
||||||
|
$released = $this->pdo->prepare('SELECT id FROM files WHERE id = ? FOR UPDATE NOWAIT');
|
||||||
|
$released->execute([$low]);
|
||||||
|
self::assertSame($low, (int) $released->fetchColumn());
|
||||||
|
$released->closeCursor();
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
$this->contender->changeFileOwner([$high, $low], $source, $noop ? $source : $target);
|
||||||
|
self::assertTrue($this->other->inTransaction());
|
||||||
|
self::assertSame(
|
||||||
|
[$noop ? $source : $target, $noop ? $source : $target],
|
||||||
|
array_column($this->files(), 'model_id'),
|
||||||
|
);
|
||||||
|
$this->other->rollBack();
|
||||||
|
self::assertSame($before, $this->files());
|
||||||
|
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM tags')->fetchColumn());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function associationTargets(): array
|
||||||
|
{
|
||||||
|
return ['reassignment' => [false], 'same-owner no-op' => [true]];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('creationMethods')]
|
||||||
|
public function testRootSerializesCreationAndLegacyLookupAfterRelease(string $method): void
|
||||||
|
{
|
||||||
|
$this->catalog->createModel('/', 'Root fixture');
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$winner = $this->catalog->$method('/contested', 'Shared title');
|
||||||
|
foreach (['createModel', 'ensureModel'] as $contenderMethod) {
|
||||||
|
$this->timeout(fn () => $this->contender->$contenderMethod('/contested', 'Shared title'));
|
||||||
|
// A disjoint folder/title cannot contend on the winner's new folder or model rows.
|
||||||
|
$this->timeout(fn () => $this->contender->$contenderMethod('/unrelated', 'Other title'));
|
||||||
|
self::assertFalse($this->other->inTransaction());
|
||||||
|
self::assertSame(1, (int) $this->other->query('SELECT COUNT(*) FROM models')->fetchColumn());
|
||||||
|
self::assertSame(1, (int) $this->other->query('SELECT COUNT(*) FROM folders')->fetchColumn());
|
||||||
|
}
|
||||||
|
$this->pdo->commit();
|
||||||
|
self::assertSame($winner, $this->contender->ensureModel('/contested', 'Shared title'));
|
||||||
|
$duplicate = $this->contender->createModel('/contested', 'Shared title');
|
||||||
|
self::assertNotSame($winner, $duplicate);
|
||||||
|
try {
|
||||||
|
$this->contender->ensureModel('/contested', 'Shared title');
|
||||||
|
self::fail('Legacy lookup must reject two explicit identities.');
|
||||||
|
} catch (DomainException $error) {
|
||||||
|
self::assertSame('Ambiguous model title in home folder.', $error->getMessage());
|
||||||
|
}
|
||||||
|
self::assertFalse($this->other->inTransaction());
|
||||||
|
self::assertSame(3, (int) $this->other->query('SELECT COUNT(*) FROM models')->fetchColumn());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function creationMethods(): array
|
||||||
|
{
|
||||||
|
return ['explicit winner' => ['createModel'], 'legacy winner' => ['ensureModel']];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function transactionOutcomes(): array
|
||||||
|
{
|
||||||
|
return ['commit' => [true], 'rollback' => [false]];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,45 @@ final class RepositoryDatabase
|
|||||||
/** @var array<string, array{schema: string, connection: PDO}> */
|
/** @var array<string, array{schema: string, connection: PDO}> */
|
||||||
private static array $schemas = [];
|
private static array $schemas = [];
|
||||||
|
|
||||||
|
/** @var array<string, Config> */
|
||||||
|
private static array $configs = [];
|
||||||
|
|
||||||
|
public static function migrateTo(string $path, int $target): void
|
||||||
|
{
|
||||||
|
self::runTarget($path, $target, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rollbackTo(string $path, int $target): void
|
||||||
|
{
|
||||||
|
self::runTarget($path, $target, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function runTarget(string $path, int $target, bool $down): void
|
||||||
|
{
|
||||||
|
$config = self::$configs[$path] ?? throw new \LogicException('Migrate this isolated path first.');
|
||||||
|
$manager = new Manager($config, new ArrayInput([]), new NullOutput());
|
||||||
|
$pdo = $config->getEnvironment('test')['connection'];
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
throw new \LogicException('Migration helpers require no active caller transaction.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if ($down) {
|
||||||
|
$manager->rollback('test', $target);
|
||||||
|
} else {
|
||||||
|
$manager->migrate('test', $target);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
throw $error;
|
||||||
|
} finally {
|
||||||
|
if (isset(self::$schemas[$path])) {
|
||||||
|
$pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function postgresConfigured(): bool
|
public static function postgresConfigured(): bool
|
||||||
{
|
{
|
||||||
$dsn = getenv('MESHVAULT_TEST_PG_DSN');
|
$dsn = getenv('MESHVAULT_TEST_PG_DSN');
|
||||||
@@ -23,9 +62,9 @@ final class RepositoryDatabase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Never a hand-maintained test schema: drift must fail the suite. */
|
/** Never a hand-maintained test schema: drift must fail the suite. */
|
||||||
public static function migrate(string $path): PDO
|
public static function migrate(string $path, ?int $target = null): PDO
|
||||||
{
|
{
|
||||||
if (isset(self::$schemas[$path])) {
|
if (isset(self::$configs[$path]) || isset(self::$schemas[$path])) {
|
||||||
throw new \LogicException('Clean up the previous database before migrating this path again.');
|
throw new \LogicException('Clean up the previous database before migrating this path again.');
|
||||||
}
|
}
|
||||||
$environment = ['adapter' => 'sqlite'];
|
$environment = ['adapter' => 'sqlite'];
|
||||||
@@ -48,7 +87,8 @@ final class RepositoryDatabase
|
|||||||
'test' => $environment + ['connection' => $pdo],
|
'test' => $environment + ['connection' => $pdo],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
|
self::$configs[$path] = $config;
|
||||||
|
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test', $target);
|
||||||
if (isset(self::$schemas[$path])) {
|
if (isset(self::$schemas[$path])) {
|
||||||
// Phinx may add public to search_path; repository queries must remain isolated.
|
// Phinx may add public to search_path; repository queries must remain isolated.
|
||||||
$pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"');
|
$pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"');
|
||||||
@@ -78,6 +118,7 @@ final class RepositoryDatabase
|
|||||||
|
|
||||||
public static function cleanup(string $path): void
|
public static function cleanup(string $path): void
|
||||||
{
|
{
|
||||||
|
unset(self::$configs[$path]);
|
||||||
if (isset(self::$schemas[$path])) {
|
if (isset(self::$schemas[$path])) {
|
||||||
['schema' => $schema, 'connection' => $pdo] = self::$schemas[$path];
|
['schema' => $schema, 'connection' => $pdo] = self::$schemas[$path];
|
||||||
if ($pdo->inTransaction()) {
|
if ($pdo->inTransaction()) {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Unit\Catalog;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use MeshVault\Catalog\ManagedPath;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ManagedPathTest extends TestCase
|
||||||
|
{
|
||||||
|
#[DataProvider('validPaths')]
|
||||||
|
public function testPreservesCanonicalSpelling(string $path): void
|
||||||
|
{
|
||||||
|
self::assertSame($path, ManagedPath::validate($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function validPaths(): array
|
||||||
|
{
|
||||||
|
return [['part.stl'], ['Folder/模型 A.STL'], ['a/.hidden'], ['a/space inside.stl']];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('invalidPaths')]
|
||||||
|
public function testRejectsAliases(string $path): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
ManagedPath::validate($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidPaths(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (string $path): array => [$path], [
|
||||||
|
'', '/a', '//server/a', 'C:/a', 'C:a', 'a\\b', "a\0b", 'https://host/a',
|
||||||
|
'file:a', 'a//b', 'a/', '.', '..', './a', 'a/../b', 'a/./b',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlatformSpecificRestrictions(): void
|
||||||
|
{
|
||||||
|
foreach (['a/b.', 'a/b ', 'a/b:c'] as $path) {
|
||||||
|
if (PHP_OS_FAMILY === 'Windows') {
|
||||||
|
try {
|
||||||
|
ManagedPath::validate($path);
|
||||||
|
self::fail('Expected Windows alias rejection');
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self::assertSame($path, ManagedPath::validate($path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user