Skip to content

Commit 68df695

Browse files
committed
Add fail-closed fidelity operation journal
1 parent b39ad68 commit 68df695

12 files changed

Lines changed: 351 additions & 37 deletions

‎src/Core/DiagnosticCode.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ enum DiagnosticCode: string
1313
case InvalidSourceDataset = 'invalid_source_dataset';
1414
case UnsupportedSqlDriver = 'unsupported_sql_driver';
1515
case SqlProfileOperationUnavailable = 'sql_profile_operation_unavailable';
16+
case FidelityLossRequiresAcceptance = 'fidelity_loss_requires_acceptance';
1617
}

‎src/Core/FidelityDiagnostic.php‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,25 @@
44

55
namespace OpenStatSpec\Core;
66

7-
/** A machine-readable statement about metadata that was not preserved. */
7+
/** A machine-readable statement about an observed or deferred fidelity boundary. */
88
final readonly class FidelityDiagnostic
99
{
10+
/** @param array<string, mixed> $details */
1011
public function __construct(
1112
public string $code,
1213
public string $message,
14+
public FidelitySeverity $severity = FidelitySeverity::Warning,
15+
public array $details = [],
1316
) {}
17+
18+
/** @return array{code: string, message: string, severity: string, details: array<string, mixed>} */
19+
public function toArray(): array
20+
{
21+
return [
22+
'code' => $this->code,
23+
'message' => $this->message,
24+
'severity' => $this->severity->value,
25+
'details' => $this->details,
26+
];
27+
}
1428
}

‎src/Core/FidelityPolicy.php‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenStatSpec\Core;
6+
7+
/** Enforces explicit acknowledgement before a known lossy export can write a file. */
8+
final class FidelityPolicy
9+
{
10+
/**
11+
* @param list<FidelityDiagnostic> $diagnostics
12+
* @param list<string> $allowLoss
13+
*/
14+
public static function assertExportAllowed(array $diagnostics, array $allowLoss): void
15+
{
16+
$accepted = array_fill_keys($allowLoss, true);
17+
$rejected = [];
18+
foreach ($diagnostics as $diagnostic) {
19+
if (!isset($accepted[$diagnostic->code])) {
20+
$rejected[$diagnostic->code] = true;
21+
}
22+
}
23+
24+
if ($rejected !== []) {
25+
throw new UnsupportedOperation(
26+
DiagnosticCode::FidelityLossRequiresAcceptance,
27+
'Export requires explicit allowLoss acknowledgement for: ' . implode(', ', array_keys($rejected)),
28+
);
29+
}
30+
}
31+
}

‎src/Core/FidelitySeverity.php‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenStatSpec\Core;
6+
7+
enum FidelitySeverity: string
8+
{
9+
case Warning = 'warning';
10+
case Error = 'error';
11+
}

‎src/Spss/SpssAdapter.php‎

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@
55
namespace OpenStatSpec\Spss;
66

77
use OpenStatSpec\Core\DiagnosticCode;
8+
use OpenStatSpec\Core\FidelityPolicy;
89
use OpenStatSpec\Core\UnsupportedOperation;
910
use OpenStatSpec\Sql\Connection;
1011
use OpenStatSpec\Sql\MySqlWideTableImporter;
1112
use OpenStatSpec\Sql\MySqlWideTableExporter;
13+
use OpenStatSpec\Sql\OperationJournal;
1214
use OpenStatSpec\Sql\PostgreSqlWideTableExporter;
1315
use OpenStatSpec\Sql\PostgreSqlWideTableImporter;
1416
use OpenStatSpec\Sql\SqliteWideTableExporter;
1517
use OpenStatSpec\Sql\SqliteWideTableImporter;
1618
use PDO;
19+
use Throwable;
1720

1821
final readonly class SpssAdapter
1922
{
@@ -31,39 +34,62 @@ public function pdo(): PDO
3134
return $this->connection->pdo;
3235
}
3336

34-
public function import(string $sourcePath, string $datasetName): void
37+
public function import(string $sourcePath, string $datasetName): SpssImportResult
3538
{
36-
if (!in_array($this->spssFormat($sourcePath), ['sav', 'zsav'], true)) {
37-
throw new UnsupportedOperation(
38-
DiagnosticCode::UnsupportedSourceFormat,
39-
'This adapter profile supports SAV and ZSAV files only.',
40-
);
39+
$journal = new OperationJournal($this->connection->pdo);
40+
$operationId = $journal->start('import', null, $sourcePath);
41+
try {
42+
if (!in_array($this->spssFormat($sourcePath), ['sav', 'zsav'], true)) {
43+
throw new UnsupportedOperation(
44+
DiagnosticCode::UnsupportedSourceFormat,
45+
'This adapter profile supports SAV and ZSAV files only.',
46+
);
47+
}
48+
$source = SpssSourceNormalizer::normalize($this->engine->read($sourcePath));
49+
match ($this->connection->profile->driverName()) {
50+
'pgsql' => (new PostgreSqlWideTableImporter($this->connection->pdo))->import($source, $datasetName),
51+
'mysql' => (new MySqlWideTableImporter($this->connection->pdo))->import($source, $datasetName),
52+
default => (new SqliteWideTableImporter($this->connection->pdo))->import($source, $datasetName),
53+
};
54+
$diagnostics = [];
55+
$journal->succeed($operationId, $datasetName, $diagnostics);
56+
57+
return new SpssImportResult($operationId, $datasetName, count($source['data']), $diagnostics);
58+
} catch (Throwable $exception) {
59+
$journal->fail($operationId, null, $exception);
60+
throw $exception;
4161
}
42-
$source = SpssSourceNormalizer::normalize($this->engine->read($sourcePath));
43-
match ($this->connection->profile->driverName()) {
44-
'pgsql' => (new PostgreSqlWideTableImporter($this->connection->pdo))->import($source, $datasetName),
45-
'mysql' => (new MySqlWideTableImporter($this->connection->pdo))->import($source, $datasetName),
46-
default => (new SqliteWideTableImporter($this->connection->pdo))->import($source, $datasetName),
47-
};
4862
}
4963

50-
public function export(string $datasetName, string $targetPath): SpssExportResult
64+
/** @param list<string> $allowLoss */
65+
public function export(string $datasetName, string $targetPath, array $allowLoss = []): SpssExportResult
5166
{
52-
$targetFormat = $this->spssFormat($targetPath);
53-
if (!in_array($targetFormat, ['sav', 'zsav'], true)) {
54-
throw new UnsupportedOperation(
55-
DiagnosticCode::UnsupportedSourceFormat,
56-
'This adapter profile exports SAV and ZSAV files only.',
57-
);
58-
}
59-
$export = match ($this->connection->profile->driverName()) {
60-
'pgsql' => (new PostgreSqlWideTableExporter($this->connection->pdo))->export($datasetName, $targetFormat),
61-
'mysql' => (new MySqlWideTableExporter($this->connection->pdo))->export($datasetName, $targetFormat),
62-
default => (new SqliteWideTableExporter($this->connection->pdo))->export($datasetName, $targetFormat),
63-
};
64-
$this->engine->write($targetPath, $export['dataset']);
67+
$journal = new OperationJournal($this->connection->pdo);
68+
$operationId = $journal->start('export', $datasetName, $targetPath, $allowLoss);
69+
$diagnostics = [];
70+
try {
71+
$targetFormat = $this->spssFormat($targetPath);
72+
if (!in_array($targetFormat, ['sav', 'zsav'], true)) {
73+
throw new UnsupportedOperation(
74+
DiagnosticCode::UnsupportedSourceFormat,
75+
'This adapter profile exports SAV and ZSAV files only.',
76+
);
77+
}
78+
$export = match ($this->connection->profile->driverName()) {
79+
'pgsql' => (new PostgreSqlWideTableExporter($this->connection->pdo))->export($datasetName, $targetFormat),
80+
'mysql' => (new MySqlWideTableExporter($this->connection->pdo))->export($datasetName, $targetFormat),
81+
default => (new SqliteWideTableExporter($this->connection->pdo))->export($datasetName, $targetFormat),
82+
};
83+
$diagnostics = $export['diagnostics'];
84+
FidelityPolicy::assertExportAllowed($diagnostics, $allowLoss);
85+
$this->engine->write($targetPath, $export['dataset']);
86+
$journal->succeed($operationId, $datasetName, $diagnostics);
6587

66-
return new SpssExportResult($datasetName, $targetPath, $export['caseCount'], $export['diagnostics']);
88+
return new SpssExportResult($operationId, $datasetName, $targetPath, $export['caseCount'], $diagnostics, $allowLoss);
89+
} catch (Throwable $exception) {
90+
$journal->fail($operationId, $datasetName, $exception, $diagnostics);
91+
throw $exception;
92+
}
6793
}
6894

6995
private function spssFormat(string $path): string

‎src/Spss/SpssExportResult.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
{
1616
/** @param list<FidelityDiagnostic> $diagnostics */
1717
public function __construct(
18+
public string $operationId,
1819
public string $datasetName,
1920
public string $targetPath,
2021
public int $caseCount,
2122
public array $diagnostics,
23+
/** @var list<string> */
24+
public array $allowLoss,
2225
) {}
2326
}

‎src/Spss/SpssImportResult.php‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenStatSpec\Spss;
6+
7+
use OpenStatSpec\Core\FidelityDiagnostic;
8+
9+
/** Outcome of an imported SAV or ZSAV dataset. */
10+
final readonly class SpssImportResult
11+
{
12+
/** @param list<FidelityDiagnostic> $diagnostics */
13+
public function __construct(
14+
public string $operationId,
15+
public string $datasetName,
16+
public int $caseCount,
17+
public array $diagnostics,
18+
) {}
19+
}

‎src/Sql/OperationJournal.php‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenStatSpec\Sql;
6+
7+
use OpenStatSpec\Core\FidelityDiagnostic;
8+
use OpenStatSpec\Core\UnsupportedOperation;
9+
use PDO;
10+
use Throwable;
11+
12+
/**
13+
* Records import/export attempts independently of a dataset catalogue entry.
14+
*
15+
* A failed source preflight therefore remains observable even though no target
16+
* dataset was created. The deliberately small SQL subset works with SQLite,
17+
* PostgreSQL, MySQL, and MariaDB through PDO.
18+
*/
19+
final readonly class OperationJournal
20+
{
21+
public function __construct(private PDO $pdo) {}
22+
23+
/** @param list<string> $allowLoss */
24+
public function start(string $direction, ?string $datasetName, ?string $targetPath, array $allowLoss = []): string
25+
{
26+
$this->createTables();
27+
$operationId = $this->operationId();
28+
$statement = $this->pdo->prepare(
29+
'INSERT INTO operation_catalog (operation_id, direction, status, dataset_name, target_path, allow_loss, failure_code, failure_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
30+
);
31+
$statement->execute([$operationId, $direction, 'running', $datasetName, $targetPath, $this->json($allowLoss), null, null]);
32+
33+
return $operationId;
34+
}
35+
36+
/** @param list<FidelityDiagnostic> $diagnostics */
37+
public function succeed(string $operationId, ?string $datasetName, array $diagnostics): void
38+
{
39+
$statement = $this->pdo->prepare('UPDATE operation_catalog SET status = ?, dataset_name = ? WHERE operation_id = ?');
40+
$statement->execute(['succeeded', $datasetName, $operationId]);
41+
$this->storeFidelityDiagnostics($operationId, $datasetName, $diagnostics);
42+
}
43+
44+
/** @param list<FidelityDiagnostic> $diagnostics */
45+
public function fail(string $operationId, ?string $datasetName, Throwable $exception, array $diagnostics = []): void
46+
{
47+
$failureCode = $exception instanceof UnsupportedOperation ? $exception->diagnosticCode->value : 'operation_failed';
48+
$statement = $this->pdo->prepare('UPDATE operation_catalog SET status = ?, dataset_name = ?, failure_code = ?, failure_message = ? WHERE operation_id = ?');
49+
$statement->execute(['failed', $datasetName, $failureCode, $exception->getMessage(), $operationId]);
50+
$this->storeFidelityDiagnostics($operationId, $datasetName, $diagnostics);
51+
}
52+
53+
private function createTables(): void
54+
{
55+
$this->pdo->exec('CREATE TABLE IF NOT EXISTS operation_catalog (operation_id VARCHAR(36) NOT NULL PRIMARY KEY, direction VARCHAR(16) NOT NULL, status VARCHAR(16) NOT NULL, dataset_name TEXT NULL, target_path TEXT NULL, allow_loss TEXT NOT NULL, failure_code VARCHAR(96) NULL, failure_message TEXT NULL)');
56+
$this->pdo->exec('CREATE TABLE IF NOT EXISTS fidelity_event_catalog (operation_id VARCHAR(36) NOT NULL, ordinal BIGINT NOT NULL, dataset_name TEXT NULL, severity VARCHAR(16) NOT NULL, code VARCHAR(96) NOT NULL, message TEXT NOT NULL, details TEXT NOT NULL, PRIMARY KEY (operation_id, ordinal))');
57+
}
58+
59+
/** @param list<FidelityDiagnostic> $diagnostics */
60+
private function storeFidelityDiagnostics(string $operationId, ?string $datasetName, array $diagnostics): void
61+
{
62+
if ($diagnostics === []) {
63+
return;
64+
}
65+
66+
$this->pdo->prepare('DELETE FROM fidelity_event_catalog WHERE operation_id = ?')->execute([$operationId]);
67+
$statement = $this->pdo->prepare('INSERT INTO fidelity_event_catalog (operation_id, ordinal, dataset_name, severity, code, message, details) VALUES (?, ?, ?, ?, ?, ?, ?)');
68+
foreach ($diagnostics as $ordinal => $diagnostic) {
69+
$statement->execute([
70+
$operationId,
71+
$ordinal + 1,
72+
$datasetName,
73+
$diagnostic->severity->value,
74+
$diagnostic->code,
75+
$diagnostic->message,
76+
$this->json($diagnostic->details),
77+
]);
78+
}
79+
}
80+
81+
/** @param array<mixed> $value */
82+
private function json(array $value): string
83+
{
84+
return json_encode($value, JSON_THROW_ON_ERROR);
85+
}
86+
87+
private function operationId(): string
88+
{
89+
$hex = bin2hex(random_bytes(16));
90+
91+
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20);
92+
}
93+
}

‎tests/Core/FidelityPolicyTest.php‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenStatSpec\Tests\Core;
6+
7+
use OpenStatSpec\Core\DiagnosticCode;
8+
use OpenStatSpec\Core\FidelityDiagnostic;
9+
use OpenStatSpec\Core\FidelityPolicy;
10+
use OpenStatSpec\Core\FidelitySeverity;
11+
use OpenStatSpec\Core\UnsupportedOperation;
12+
use PHPUnit\Framework\TestCase;
13+
14+
final class FidelityPolicyTest extends TestCase
15+
{
16+
public function testExportRequiresExplicitAcknowledgementOfEveryKnownLoss(): void
17+
{
18+
$diagnostic = new FidelityDiagnostic(
19+
'deferred-feature',
20+
'The target engine cannot reproduce this feature.',
21+
FidelitySeverity::Warning,
22+
['feature' => 'example'],
23+
);
24+
25+
try {
26+
FidelityPolicy::assertExportAllowed([$diagnostic], []);
27+
self::fail('The known loss must require an explicit acknowledgement.');
28+
} catch (UnsupportedOperation $exception) {
29+
self::assertSame(DiagnosticCode::FidelityLossRequiresAcceptance, $exception->diagnosticCode);
30+
self::assertSame('Export requires explicit allowLoss acknowledgement for: deferred-feature', $exception->getMessage());
31+
}
32+
33+
FidelityPolicy::assertExportAllowed([$diagnostic], ['deferred-feature']);
34+
self::assertSame(
35+
[
36+
'code' => 'deferred-feature',
37+
'message' => 'The target engine cannot reproduce this feature.',
38+
'severity' => 'warning',
39+
'details' => ['feature' => 'example'],
40+
],
41+
$diagnostic->toArray(),
42+
);
43+
}
44+
}

0 commit comments

Comments
 (0)