diff --git a/src/Core/Binary64.php b/src/Core/Binary64.php index 7e3a933..c17d2fa 100644 --- a/src/Core/Binary64.php +++ b/src/Core/Binary64.php @@ -5,15 +5,12 @@ namespace OpenStatSpec\Core; use InvalidArgumentException; -use JsonException; final class Binary64 { /** - * Encode a numeric SPSS dictionary value without PDO's default + * Encode a numeric SPSS value without PDO's default * precision-losing float-to-string conversion. - * - * @throws JsonException */ public static function encode(int|float $value): string { @@ -22,6 +19,8 @@ public static function encode(int|float $value): string throw new InvalidArgumentException('SPSS binary64 dictionary values must be finite.'); } - return json_encode($float, JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR); + $encoded = sprintf('%.17H', $float); + + return strpbrk($encoded, '.eE') === false ? $encoded . '.0' : $encoded; } } diff --git a/src/Spss/SpssAdapter.php b/src/Spss/SpssAdapter.php index bb12480..e433079 100644 --- a/src/Spss/SpssAdapter.php +++ b/src/Spss/SpssAdapter.php @@ -134,63 +134,80 @@ public function import( string $datasetName, ?string $verifiedSourceSha256 = null, ): SpssImportResult { - $this->assertLogicalSourcePath($sourcePath); - $verifiedSourceSha256 = NormativeCatalog::validateSourceSha256($verifiedSourceSha256); - $this->connection->assertClaimedSupported(); - $this->ensureCatalogReady(); - $sourceFormat = $this->spssFormat($sourcePath); - $journal = new OperationJournal($this->connection->pdo); - $operationId = $journal->start('import', null, $sourcePath, engineDetails: $this->engine->identity(), sourceFormat: $sourceFormat); - $mySqlDefinition = null; + if ($this->connection->pdo->inTransaction()) { + throw new UnsupportedOperation(DiagnosticCode::UnsupportedOperation, 'Import cannot run inside a caller-owned transaction.'); + } + $errorMode = $this->connection->pdo->getAttribute(PDO::ATTR_ERRMODE); try { - if (!in_array($sourceFormat, ['sav', 'zsav'], true)) { - throw new UnsupportedOperation( - DiagnosticCode::UnsupportedSourceFormat, - 'This adapter profile supports SAV and ZSAV files only.', - ); - } - $source = SpssSourceNormalizer::normalize($this->engine->read($sourcePath)); - if ($this->connection->profile->driverName() === 'pgsql') { - (new PostgreSqlWideTableImporter($this->connection->pdo))->import( - $source, - $datasetName, - $sourcePath, - $verifiedSourceSha256, - ); - } elseif ($this->connection->profile->driverName() === 'mysql') { - $mySqlDefinition = (new MySqlWideTableImporter( - $this->connection->pdo, - $this->mySqlProfile(), - ))->import($source, $datasetName, $sourcePath, $verifiedSourceSha256); - } else { - (new SqliteWideTableImporter($this->connection->pdo))->import( - $source, - $datasetName, - $sourcePath, - $verifiedSourceSha256, - ); - } - if ($this->beforeImportFinalization !== null) { - ($this->beforeImportFinalization)(); + if (!$this->connection->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)) { + throw new \RuntimeException('Could not enable PDO exception mode.'); } - $diagnostics = []; - $journal->succeed($operationId, $datasetName, $diagnostics); - - return new SpssImportResult($operationId, $datasetName, count($source['data']), $diagnostics); - } catch (Throwable $exception) { - $failure = $exception; - if ($mySqlDefinition instanceof MySqlWideTableDefinition) { - try { - (new MySqlWideTableImporter( + $this->assertLogicalSourcePath($sourcePath); + $verifiedSourceSha256 = NormativeCatalog::validateSourceSha256($verifiedSourceSha256); + $this->connection->assertClaimedSupported(); + $this->ensureCatalogReady(); + $sourceFormat = $this->spssFormat($sourcePath); + $journal = new OperationJournal($this->connection->pdo); + $operationId = $journal->start('import', null, $sourcePath, engineDetails: $this->engine->identity(), sourceFormat: $sourceFormat); + $finalize = function () use ($journal, $operationId, $datasetName): void { + if ($this->beforeImportFinalization !== null) { + ($this->beforeImportFinalization)(); + } + $journal->succeed($operationId, $datasetName, []); + }; + $mySqlDefinition = null; + try { + if (!in_array($sourceFormat, ['sav', 'zsav'], true)) { + throw new UnsupportedOperation( + DiagnosticCode::UnsupportedSourceFormat, + 'This adapter profile supports SAV and ZSAV files only.', + ); + } + $source = SpssSourceNormalizer::normalize($this->engine->read($sourcePath)); + if ($this->connection->profile->driverName() === 'pgsql') { + (new PostgreSqlWideTableImporter($this->connection->pdo))->import( + $source, + $datasetName, + $sourcePath, + $verifiedSourceSha256, + $finalize, + ); + } elseif ($this->connection->profile->driverName() === 'mysql') { + $mySqlDefinition = (new MySqlWideTableImporter( $this->connection->pdo, $this->mySqlProfile(), - ))->compensateFailure($datasetName, $mySqlDefinition); - } catch (Throwable $cleanupFailure) { - $failure = $cleanupFailure; + ))->import($source, $datasetName, $sourcePath, $verifiedSourceSha256); + $finalize(); + } else { + (new SqliteWideTableImporter($this->connection->pdo))->import( + $source, + $datasetName, + $sourcePath, + $verifiedSourceSha256, + $finalize, + ); } + + return new SpssImportResult($operationId, $datasetName, count($source['data']), []); + } catch (Throwable $exception) { + $failure = $exception; + if ($mySqlDefinition instanceof MySqlWideTableDefinition) { + try { + (new MySqlWideTableImporter( + $this->connection->pdo, + $this->mySqlProfile(), + ))->compensateFailure($datasetName, $mySqlDefinition); + } catch (Throwable $cleanupFailure) { + $failure = $cleanupFailure; + } + } + $journal->fail($operationId, null, $failure, sourceItem: $sourcePath); + throw $failure; + } + } finally { + if (!$this->connection->pdo->setAttribute(PDO::ATTR_ERRMODE, $errorMode)) { + throw new \RuntimeException('Could not restore PDO error mode.'); } - $journal->fail($operationId, null, $failure, sourceItem: $sourcePath); - throw $failure; } } diff --git a/src/Sql/MySqlWideTableImporter.php b/src/Sql/MySqlWideTableImporter.php index d43be05..6c100c6 100644 --- a/src/Sql/MySqlWideTableImporter.php +++ b/src/Sql/MySqlWideTableImporter.php @@ -34,71 +34,88 @@ public function import( string $sourcePath = "", ?string $verifiedSourceSha256 = null, ): MySqlWideTableDefinition { - $verifiedSourceSha256 = NormativeCatalog::validateSourceSha256($verifiedSourceSha256); - $variables = $source['variables'] ?? null; - $rows = $source['data'] ?? null; - if (!is_array($variables) || !array_is_list($variables) || $variables === []) { - throw new UnsupportedOperation( - DiagnosticCode::InvalidSourceDataset, - 'The source dataset must contain an ordered variable list.', - ); - } - if (!is_array($rows) || !array_is_list($rows)) { - throw new UnsupportedOperation( - DiagnosticCode::InvalidSourceDataset, - 'The source dataset must contain an ordered case list.', - ); + if ($this->pdo->inTransaction()) { + throw new UnsupportedOperation(DiagnosticCode::UnsupportedOperation, 'Import cannot run inside a caller-owned transaction.'); } - - $this->profile->assertDataset($variables, $rows, $this->pdo); - - $schema = new MySqlSchema($this->pdo, $this->profile); - // Complete source, physical-name, and width preflight happens before any DDL. - $definition = $schema->wideTableDefinition($datasetName, $variables); - $v3Metadata = $this->assertSourceMetadata($source, $variables, $definition); - - $schema->createCatalog(); - $this->pdo->exec($definition->createSql); - - $ownedDefinition = $definition; + $errorMode = $this->pdo->getAttribute(PDO::ATTR_ERRMODE); try { - $this->pdo->beginTransaction(); - $this->storeDatasetMetadata($datasetName, $source); - $this->storeTechnicalMetadata($datasetName, $source); - $this->storeCatalogue($datasetName, $variables, $definition); - $this->storeWeightVariable($datasetName, $source['weightVariableName'] ?? null, $definition); - $this->storeDisplayMetadata($datasetName, $source['displayParameters'] ?? []); - $this->storeDictionaryMetadata($datasetName, $variables, $source['valueLabels'] ?? []); - if ($v3Metadata !== null) { - (new SqliteV3MetadataImporter($this->pdo))->storeValidated($datasetName, $v3Metadata); + if (!$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)) { + throw new \RuntimeException('Could not enable PDO exception mode.'); } - $this->insertCases($definition, $rows); - if ($sourcePath !== "" || $verifiedSourceSha256 !== null) { - $datasetId = (new NormativeCatalog($this->pdo))->storeImportedDataset( - $datasetName, - $sourcePath, - $source, - $verifiedSourceSha256, + $verifiedSourceSha256 = NormativeCatalog::validateSourceSha256($verifiedSourceSha256); + $variables = $source['variables'] ?? null; + $rows = $source['data'] ?? null; + if (!is_array($variables) || !array_is_list($variables) || $variables === []) { + throw new UnsupportedOperation( + DiagnosticCode::InvalidSourceDataset, + 'The source dataset must contain an ordered variable list.', ); - $ownedDefinition = new MySqlWideTableDefinition( - $definition->tableName, - $definition->createSql, - $definition->columns, - $datasetId, + } + if (!is_array($rows) || !array_is_list($rows)) { + throw new UnsupportedOperation( + DiagnosticCode::InvalidSourceDataset, + 'The source dataset must contain an ordered case list.', ); } - $this->pdo->commit(); - } catch (Throwable $exception) { - if ($this->pdo->inTransaction()) { - $this->pdo->rollBack(); + + $this->profile->assertDataset($variables, $rows, $this->pdo); + + $schema = new MySqlSchema($this->pdo, $this->profile); + // Complete source, physical-name, and width preflight happens before any DDL. + $definition = $schema->wideTableDefinition($datasetName, $variables); + $v3Metadata = $this->assertSourceMetadata($source, $variables, $definition); + + $schema->createCatalog(); + $this->pdo->exec($definition->createSql); + + $ownedDefinition = $definition; + try { + if (!$this->pdo->beginTransaction()) { + throw new \RuntimeException('Could not begin import transaction.'); + } + $this->storeDatasetMetadata($datasetName, $source); + $this->storeTechnicalMetadata($datasetName, $source); + $this->storeCatalogue($datasetName, $variables, $definition); + $this->storeWeightVariable($datasetName, $source['weightVariableName'] ?? null, $definition); + $this->storeDisplayMetadata($datasetName, $source['displayParameters'] ?? []); + $this->storeDictionaryMetadata($datasetName, $variables, $source['valueLabels'] ?? []); + if ($v3Metadata !== null) { + (new SqliteV3MetadataImporter($this->pdo))->storeValidated($datasetName, $v3Metadata); + } + $this->insertCases($definition, $rows); + if ($sourcePath !== "" || $verifiedSourceSha256 !== null) { + $datasetId = (new NormativeCatalog($this->pdo))->storeImportedDataset( + $datasetName, + $sourcePath, + $source, + $verifiedSourceSha256, + ); + $ownedDefinition = new MySqlWideTableDefinition( + $definition->tableName, + $definition->createSql, + $definition->columns, + $datasetId, + ); + } + if (!$this->pdo->commit()) { + throw new \RuntimeException('Could not commit import transaction.'); + } + } catch (Throwable $exception) { + if ($this->pdo->inTransaction() && !$this->pdo->rollBack()) { + throw new \RuntimeException('Could not roll back import transaction.', previous: $exception); + } + + // Catalogue DML was rolled back. Only the physical DDL survives. + $this->dropPhysicalTable($definition); + throw $exception; } - // Catalogue DML was rolled back. Only the physical DDL survives. - $this->dropPhysicalTable($definition); - throw $exception; + return $ownedDefinition; + } finally { + if (!$this->pdo->setAttribute(PDO::ATTR_ERRMODE, $errorMode)) { + throw new \RuntimeException('Could not restore PDO error mode.'); + } } - - return $ownedDefinition; } /** diff --git a/src/Sql/NormativeCatalog.php b/src/Sql/NormativeCatalog.php index f327598..c5d5544 100644 --- a/src/Sql/NormativeCatalog.php +++ b/src/Sql/NormativeCatalog.php @@ -395,7 +395,7 @@ private function storeMultipleResponseSets(string $datasetId, array $variableIds $counted = $set['countedValue'] ?? null; $countedKind = is_string($counted) ? 'string' : (is_int($counted) || is_float($counted) ? 'numeric' : null); $this->statement('INSERT INTO multiple_response_set (multiple_response_set_id, dataset_id, source_ordinal, set_name, set_label, set_kind, counted_value_kind, counted_numeric_value, counted_string_value, category_label_behavior, label_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')->execute([ - $setId, $datasetId, $setOrdinal + 1, $set['name'], is_string($set['label'] ?? null) ? $set['label'] : null, $set['type'] === 'dichotomy' ? 'MD' : 'MC', $countedKind, is_int($counted) || is_float($counted) ? $counted : null, is_string($counted) ? $counted : null, is_string($set['categoryLabels'] ?? null) ? $set['categoryLabels'] : null, is_string($set['labelSource'] ?? null) ? $set['labelSource'] : null, + $setId, $datasetId, $setOrdinal + 1, $set['name'], is_string($set['label'] ?? null) ? $set['label'] : null, $set['type'] === 'dichotomy' ? 'MD' : 'MC', $countedKind, is_float($counted) ? Binary64::encode($counted) : (is_int($counted) ? $counted : null), is_string($counted) ? $counted : null, is_string($set['categoryLabels'] ?? null) ? $set['categoryLabels'] : null, is_string($set['labelSource'] ?? null) ? $set['labelSource'] : null, ]); foreach ($this->list($set['variableNames'] ?? null, 'Multiple-response members') as $ordinal => $name) { $variableOrdinal = $this->variableOrdinal($variables, $name); diff --git a/src/Sql/PostgreSqlWideTableImporter.php b/src/Sql/PostgreSqlWideTableImporter.php index 0ea9f7a..0a78dbb 100644 --- a/src/Sql/PostgreSqlWideTableImporter.php +++ b/src/Sql/PostgreSqlWideTableImporter.php @@ -24,34 +24,52 @@ public function __construct(private PDO $pdo) {} /** @param array $source */ public function createTables(array $source, string $datasetName): PostgreSqlWideTableDefinition { - $variables = $source['variables'] ?? null; - if (!is_array($variables) || !array_is_list($variables) || $variables === []) { - throw new UnsupportedOperation( - DiagnosticCode::InvalidSourceDataset, - 'The source dataset must contain an ordered variable list.', - ); + if ($this->pdo->inTransaction()) { + throw new UnsupportedOperation(DiagnosticCode::UnsupportedOperation, 'Import cannot run inside a caller-owned transaction.'); } + $pdo = $this->pdo; + $errorMode = $pdo->getAttribute(PDO::ATTR_ERRMODE); + try { + if (!$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)) { + throw new \RuntimeException('Could not enable PDO exception mode.'); + } + $variables = $source['variables'] ?? null; + if (!is_array($variables) || !array_is_list($variables) || $variables === []) { + throw new UnsupportedOperation( + DiagnosticCode::InvalidSourceDataset, + 'The source dataset must contain an ordered variable list.', + ); + } - V3MetadataPlan::fromSourceIfPresent($source); + V3MetadataPlan::fromSourceIfPresent($source); - $schema = new PostgreSqlSchema($this->pdo); - // Validate the complete physical mapping before any DDL starts. - $definition = $schema->wideTableDefinition($datasetName, $variables); + $schema = new PostgreSqlSchema($pdo); + // Validate the complete physical mapping before any DDL starts. + $definition = $schema->wideTableDefinition($datasetName, $variables); - $this->pdo->beginTransaction(); - try { - $schema->createCatalog(); - $this->pdo->exec($definition->createSql); - $this->pdo->commit(); - } catch (Throwable $exception) { - if ($this->pdo->inTransaction()) { - $this->pdo->rollBack(); + if (!$pdo->beginTransaction()) { + throw new \RuntimeException('Could not begin import transaction.'); + } + try { + $schema->createCatalog(); + $pdo->exec($definition->createSql); + if (!$pdo->commit()) { + throw new \RuntimeException('Could not commit import transaction.'); + } + } catch (Throwable $exception) { + if ($pdo->inTransaction() && !$pdo->rollBack()) { + throw new \RuntimeException('Could not roll back import transaction.', previous: $exception); + } + + throw $exception; } - throw $exception; + return $definition; + } finally { + if (!$pdo->setAttribute(PDO::ATTR_ERRMODE, $errorMode)) { + throw new \RuntimeException('Could not restore PDO error mode.'); + } } - - return $definition; } /** * Store the normalized cases and core dictionary in one strict @@ -64,55 +82,76 @@ public function import( string $datasetName, string $sourcePath = "", ?string $verifiedSourceSha256 = null, + ?callable $beforeCommit = null, ): PostgreSqlWideTableDefinition { - $verifiedSourceSha256 = NormativeCatalog::validateSourceSha256($verifiedSourceSha256); - $variables = $source['variables'] ?? null; - $rows = $source['data'] ?? null; - if (!is_array($variables) || !array_is_list($variables) || $variables === []) { - throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'The source dataset must contain an ordered variable list.'); - } - if (!is_array($rows) || !array_is_list($rows)) { - throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'The source dataset must contain an ordered case list.'); + if ($this->pdo->inTransaction()) { + throw new UnsupportedOperation(DiagnosticCode::UnsupportedOperation, 'Import cannot run inside a caller-owned transaction.'); } + $errorMode = $this->pdo->getAttribute(PDO::ATTR_ERRMODE); + try { + if (!$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)) { + throw new \RuntimeException('Could not enable PDO exception mode.'); + } + $verifiedSourceSha256 = NormativeCatalog::validateSourceSha256($verifiedSourceSha256); + $variables = $source['variables'] ?? null; + $rows = $source['data'] ?? null; + if (!is_array($variables) || !array_is_list($variables) || $variables === []) { + throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'The source dataset must contain an ordered variable list.'); + } + if (!is_array($rows) || !array_is_list($rows)) { + throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'The source dataset must contain an ordered case list.'); + } - (new PostgreSqlProfile())->assertDataset($variables, $rows, $this->pdo); - $v3Metadata = V3MetadataPlan::fromSourceIfPresent($source); + (new PostgreSqlProfile())->assertDataset($variables, $rows, $this->pdo); + $v3Metadata = V3MetadataPlan::fromSourceIfPresent($source); - $schema = new PostgreSqlSchema($this->pdo); - // Preflight the entire physical-name mapping before changing the target. - $definition = $schema->wideTableDefinition($datasetName, $variables); + $schema = new PostgreSqlSchema($this->pdo); + // Preflight the entire physical-name mapping before changing the target. + $definition = $schema->wideTableDefinition($datasetName, $variables); - $this->pdo->beginTransaction(); - try { - $schema->createCatalog(); - $this->pdo->exec($definition->createSql); - $this->storeDatasetMetadata($datasetName, $source); - $this->storeTechnicalMetadata($datasetName, $source); - $this->storeCatalogue($datasetName, $variables, $definition); - $this->storeWeightVariable($datasetName, $source['weightVariableName'] ?? null, $definition); - $this->storeDisplayMetadata($datasetName, $source['displayParameters'] ?? []); - $this->storeDictionaryMetadata($datasetName, $variables, $source['valueLabels'] ?? []); - if ($v3Metadata !== null) { - (new SqliteV3MetadataImporter($this->pdo))->storeValidated($datasetName, $v3Metadata); + if (!$this->pdo->beginTransaction()) { + throw new \RuntimeException('Could not begin import transaction.'); } - $this->insertCases($definition, $rows); - if ($sourcePath !== "" || $verifiedSourceSha256 !== null) { - (new NormativeCatalog($this->pdo))->storeImportedDataset( - $datasetName, - $sourcePath, - $source, - $verifiedSourceSha256, - ); + try { + $schema->createCatalog(); + $this->pdo->exec($definition->createSql); + $this->storeDatasetMetadata($datasetName, $source); + $this->storeTechnicalMetadata($datasetName, $source); + $this->storeCatalogue($datasetName, $variables, $definition); + $this->storeWeightVariable($datasetName, $source['weightVariableName'] ?? null, $definition); + $this->storeDisplayMetadata($datasetName, $source['displayParameters'] ?? []); + $this->storeDictionaryMetadata($datasetName, $variables, $source['valueLabels'] ?? []); + if ($v3Metadata !== null) { + (new SqliteV3MetadataImporter($this->pdo))->storeValidated($datasetName, $v3Metadata); + } + $this->insertCases($definition, $rows); + if ($sourcePath !== "" || $verifiedSourceSha256 !== null) { + (new NormativeCatalog($this->pdo))->storeImportedDataset( + $datasetName, + $sourcePath, + $source, + $verifiedSourceSha256, + ); + } + if ($beforeCommit !== null) { + $beforeCommit(); + } + if (!$this->pdo->commit()) { + throw new \RuntimeException('Could not commit import transaction.'); + } + } catch (Throwable $exception) { + if ($this->pdo->inTransaction() && !$this->pdo->rollBack()) { + throw new \RuntimeException('Could not roll back import transaction.', previous: $exception); + } + throw $exception; } - $this->pdo->commit(); - } catch (Throwable $exception) { - if ($this->pdo->inTransaction()) { - $this->pdo->rollBack(); + + return $definition; + } finally { + if (!$this->pdo->setAttribute(PDO::ATTR_ERRMODE, $errorMode)) { + throw new \RuntimeException('Could not restore PDO error mode.'); } - throw $exception; } - - return $definition; } /** @param array $source */ diff --git a/src/Sql/SqliteV3MetadataImporter.php b/src/Sql/SqliteV3MetadataImporter.php index f1222db..e9fbfaf 100644 --- a/src/Sql/SqliteV3MetadataImporter.php +++ b/src/Sql/SqliteV3MetadataImporter.php @@ -4,6 +4,7 @@ namespace OpenStatSpec\Sql; +use OpenStatSpec\Core\Binary64; use PDO; /** Persists canonically validated V3 SPSS metadata beside a strict wide data table. */ @@ -95,7 +96,7 @@ private function storeMultipleResponseSets(string $datasetName, array $sets): vo $set['type'], $set['label'], $countedValue === null ? null : (is_string($countedValue) ? 'text' : 'numeric'), - is_int($countedValue) || is_float($countedValue) ? (float) $countedValue : null, + is_int($countedValue) || is_float($countedValue) ? Binary64::encode($countedValue) : null, is_string($countedValue) ? $countedValue : null, $set['categoryLabels'], $set['labelSource'], diff --git a/src/Sql/SqliteWideTableImporter.php b/src/Sql/SqliteWideTableImporter.php index f88b9b4..9dac1e8 100644 --- a/src/Sql/SqliteWideTableImporter.php +++ b/src/Sql/SqliteWideTableImporter.php @@ -26,47 +26,68 @@ public function import( string $datasetName, string $sourcePath = "", ?string $verifiedSourceSha256 = null, + ?callable $beforeCommit = null, ): void { - $verifiedSourceSha256 = NormativeCatalog::validateSourceSha256($verifiedSourceSha256); - $variables = $this->variables($source['variables']); - $sourceRows = $source['data'] ?? null; - if (!is_array($sourceRows) || !array_is_list($sourceRows)) { - throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'The source dataset must contain an ordered case list.'); + if ($this->pdo->inTransaction()) { + throw new UnsupportedOperation(DiagnosticCode::UnsupportedOperation, 'Import cannot run inside a caller-owned transaction.'); } - $this->profile->assertDataset($source['variables'], $sourceRows, $this->pdo); - $v3Metadata = V3MetadataPlan::fromSource($source); - $tableName = 'dataset_' . $this->identifier($datasetName); - - $this->pdo->beginTransaction(); + $errorMode = $this->pdo->getAttribute(PDO::ATTR_ERRMODE); try { - $this->createCatalog(); - $this->storeDatasetMetadata($datasetName, $source); - $this->storeTechnicalMetadata($datasetName, $source); - $this->storeDictionaryMetadata($datasetName, $source['variables'], $source['valueLabels'] ?? []); - (new SqliteV3MetadataImporter($this->pdo))->storeValidated($datasetName, $v3Metadata); - $this->storeDisplayMetadata($datasetName, is_array($source['displayParameters'] ?? null) ? $source['displayParameters'] : []); - $this->createDataTable($tableName, $variables); - $this->pdo->prepare('INSERT INTO datasets (dataset_name, table_name) VALUES (?, ?)')->execute([$datasetName, $tableName]); - $catalog = $this->pdo->prepare('INSERT INTO variables (dataset_name, ordinal, source_name, column_name, storage_kind, source_width, format_family, format_width, format_decimals, write_format_family, write_format_width, write_format_decimals, label) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); - foreach ($variables as $variable) { - $catalog->execute([$datasetName, $variable['ordinal'], $variable['source'], $variable['column'], $variable['kind'], $variable['width'], $variable['formatFamily'], $variable['formatWidth'], $variable['formatDecimals'], $variable['writeFormatFamily'], $variable['writeFormatWidth'], $variable['writeFormatDecimals'], $variable['label']]); + if (!$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)) { + throw new \RuntimeException('Could not enable PDO exception mode.'); + } + $verifiedSourceSha256 = NormativeCatalog::validateSourceSha256($verifiedSourceSha256); + $variables = $this->variables($source['variables']); + $sourceRows = $source['data'] ?? null; + if (!is_array($sourceRows) || !array_is_list($sourceRows)) { + throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'The source dataset must contain an ordered case list.'); } - $this->storeWeightVariable($datasetName, $source['weightVariableName'] ?? null, $variables); - $this->insertCases($tableName, $variables, $sourceRows); - if ($sourcePath !== "" || $verifiedSourceSha256 !== null) { - (new NormativeCatalog($this->pdo))->storeImportedDataset( - $datasetName, - $sourcePath, - $source, - $verifiedSourceSha256, - ); + $this->profile->assertDataset($source['variables'], $sourceRows, $this->pdo); + $v3Metadata = V3MetadataPlan::fromSource($source); + $tableName = 'dataset_' . $this->identifier($datasetName); + + if (!$this->pdo->beginTransaction()) { + throw new \RuntimeException('Could not begin import transaction.'); + } + try { + $this->createCatalog(); + $this->storeDatasetMetadata($datasetName, $source); + $this->storeTechnicalMetadata($datasetName, $source); + $this->storeDictionaryMetadata($datasetName, $source['variables'], $source['valueLabels'] ?? []); + (new SqliteV3MetadataImporter($this->pdo))->storeValidated($datasetName, $v3Metadata); + $this->storeDisplayMetadata($datasetName, is_array($source['displayParameters'] ?? null) ? $source['displayParameters'] : []); + $this->createDataTable($tableName, $variables); + $this->pdo->prepare('INSERT INTO datasets (dataset_name, table_name) VALUES (?, ?)')->execute([$datasetName, $tableName]); + $catalog = $this->pdo->prepare('INSERT INTO variables (dataset_name, ordinal, source_name, column_name, storage_kind, source_width, format_family, format_width, format_decimals, write_format_family, write_format_width, write_format_decimals, label) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + foreach ($variables as $variable) { + $catalog->execute([$datasetName, $variable['ordinal'], $variable['source'], $variable['column'], $variable['kind'], $variable['width'], $variable['formatFamily'], $variable['formatWidth'], $variable['formatDecimals'], $variable['writeFormatFamily'], $variable['writeFormatWidth'], $variable['writeFormatDecimals'], $variable['label']]); + } + $this->storeWeightVariable($datasetName, $source['weightVariableName'] ?? null, $variables); + $this->insertCases($tableName, $variables, $sourceRows); + if ($sourcePath !== "" || $verifiedSourceSha256 !== null) { + (new NormativeCatalog($this->pdo))->storeImportedDataset( + $datasetName, + $sourcePath, + $source, + $verifiedSourceSha256, + ); + } + if ($beforeCommit !== null) { + $beforeCommit(); + } + if (!$this->pdo->commit()) { + throw new \RuntimeException('Could not commit import transaction.'); + } + } catch (Throwable $exception) { + if ($this->pdo->inTransaction() && !$this->pdo->rollBack()) { + throw new \RuntimeException('Could not roll back import transaction.', previous: $exception); + } + throw $exception; } - $this->pdo->commit(); - } catch (Throwable $exception) { - if ($this->pdo->inTransaction()) { - $this->pdo->rollBack(); + } finally { + if (!$this->pdo->setAttribute(PDO::ATTR_ERRMODE, $errorMode)) { + throw new \RuntimeException('Could not restore PDO error mode.'); } - throw $exception; } } @@ -167,9 +188,9 @@ private function technicalInt(mixed $value): ?int return is_int($value) ? $value : null; } - private function technicalFloat(mixed $value): ?float + private function technicalFloat(mixed $value): ?string { - return is_float($value) || is_int($value) ? (float) $value : null; + return is_float($value) || is_int($value) ? Binary64::encode($value) : null; } /** @param list $variables */ @@ -290,7 +311,7 @@ private function insertCases(string $tableName, array $variables, array $rows): if ($value === null && $variable['kind'] === 'string') { throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'SPSS string values must not be represented as NULL.'); } - $values[$variable['column']] = $value; + $values[$variable['column']] = is_float($value) ? Binary64::encode($value) : $value; } $statement->execute($values); } diff --git a/tests/Integration/PostgreSqlSpssRoundTripTest.php b/tests/Integration/PostgreSqlSpssRoundTripTest.php index 3902a01..355745c 100644 --- a/tests/Integration/PostgreSqlSpssRoundTripTest.php +++ b/tests/Integration/PostgreSqlSpssRoundTripTest.php @@ -20,8 +20,10 @@ use OpenStatSpec\Transformation\Plan\TransformationPlan; use OpenStatSpec\Transformation\Plan\Value\Binary64Value; use OpenStatSpec\Spss\SpssAdapter; +use OpenStatSpec\Tests\Support\FakeSpssEngine; use PDO; use PDOException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; use SPSS\Sav\Alignment; @@ -197,6 +199,68 @@ public function testDeleteThenCreateAtThePostgreSqlPhysicalColumnLimitFailsBefor } } + /** @return iterable */ + public static function finalizationFailures(): iterable + { + yield 'hook throws' => [false]; + yield 'normative success update fails' => [true]; + } + + #[DataProvider('finalizationFailures')] + public function testImportFinalizationFailureRollsBackOnlyThisAttempt(bool $journalFailure): void + { + $pdo = $this->postgres(); + $schema = 'import_atomicity_' . bin2hex(random_bytes(6)); + $pdo->exec('CREATE SCHEMA ' . $this->quote($schema)); + try { + $pdo->exec('SET search_path TO ' . $this->quote($schema)); + $engine = new FakeSpssEngine($this->fixture('sav')); + $prior = (new SpssAdapter($pdo, $engine))->import('prior.sav', 'prior'); + $before = []; + foreach ($this->rows($pdo, "SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' AND table_name NOT IN ('operation_catalog', 'operation', 'fidelity_event_catalog', 'fidelity_event')", []) as $table) { + $before[$table['table_name']] = $this->rows($pdo, 'SELECT * FROM ' . $this->quote($table['table_name']), []); + } + $inTransaction = false; + $injected = new RuntimeException('Injected finalization failure.'); + $adapter = new SpssAdapter($pdo, $engine, beforeImportFinalization: static function () use ($pdo, $prior, $journalFailure, $injected, &$inTransaction): void { + $inTransaction = $pdo->inTransaction(); + if (!$journalFailure) { + throw $injected; + } + $pdo->exec("ALTER TABLE operation ADD CONSTRAINT reject_success CHECK (status <> 'succeeded' OR operation_id = '{$prior->operationId}')"); + }); + try { + $adapter->import('attempt.sav', 'attempt'); + self::fail('Finalization failure was swallowed.'); + } catch (RuntimeException $exception) { + if ($journalFailure) { + self::assertInstanceOf(PDOException::class, $exception); + self::assertStringContainsString('reject_success', $exception->getMessage()); + } else { + self::assertSame($injected, $exception); + } + } + self::assertFalse($pdo->inTransaction()); + self::assertNull($this->scalar($pdo, "SELECT to_regclass('dataset_attempt')", [])); + foreach ($before as $table => $rows) { + self::assertEqualsCanonicalizing($rows, $this->rows($pdo, 'SELECT * FROM ' . $this->quote($table), []), $table); + } + self::assertTrue($inTransaction, 'Finalization must share the dataset transaction.'); + self::assertSame([ + ['target_path' => 'attempt.sav', 'status' => 'failed', 'dataset_name' => null, 'normative_status' => 'failed'], + ['target_path' => 'prior.sav', 'status' => 'succeeded', 'dataset_name' => 'prior', 'normative_status' => 'succeeded'], + ], $this->rows($pdo, 'SELECT target_path, legacy.status, dataset_name, normative.status AS normative_status FROM operation_catalog legacy JOIN operation normative USING (operation_id) ORDER BY target_path', [])); + self::assertSame([['dataset_name' => null, 'code' => 'operation_failed']], $this->rows($pdo, 'SELECT dataset_name, code FROM fidelity_event_catalog', [])); + self::assertSame([['dataset_id' => null, 'event_code' => 'operation_failed']], $this->rows($pdo, 'SELECT dataset_id, event_code FROM fidelity_event', [])); + } finally { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $pdo->exec('SET search_path TO public'); + $pdo->exec('DROP SCHEMA ' . $this->quote($schema) . ' CASCADE'); + } + } + private function postgres(): PDO { $dsn = getenv('OPENSTATSPEC_PG_DSN'); diff --git a/tests/Spss/SpssAdapterTest.php b/tests/Spss/SpssAdapterTest.php index a05a795..4c6ef3f 100644 --- a/tests/Spss/SpssAdapterTest.php +++ b/tests/Spss/SpssAdapterTest.php @@ -1164,6 +1164,211 @@ public function testV3MigrationRestoresSetOrdinalConstraints(): void $pdo->exec("INSERT INTO variable_set (variable_set_id, dataset_id, source_ordinal, set_name) VALUES ('vs-duplicate', 'dataset-v3', 1, 'Duplicate')"); } + /** @return iterable */ + public static function importEntryPoints(): iterable + { + yield 'adapter' => [false]; + yield 'direct normalized source' => [true]; + } + + /** @return iterable */ + public static function binary64ImportCases(): iterable + { + foreach (self::importEntryPoints() as $entry => [$direct]) { + yield $entry . ' default precision' => [$direct, '-1', 1.2345678901234567]; + yield $entry . ' tiny float' => [$direct, '-1', -7.425696547609993e-37]; + yield $entry . ' serialize_precision=3' => [$direct, '3', 1.234]; + } + } + + #[DataProvider('binary64ImportCases')] + public function testImportPreservesBinary64CasesAndNumericMetadata(bool $direct, string $precision, float $value): void + { + $pdo = new PDO('sqlite::memory:'); + $counted = 9007199254740991; + $source = new Dataset( + $this->fixture()->dictionary, + [[$value, 'blue'], [null, ''], [7, 'green']], + new FileMetadata(multipleResponseSets: [ + new MultipleResponseSet('$Selected', MultipleResponseSetType::DICHOTOMY, ['Respondent ID'], countedValue: $counted), + ]), + new FileTechnicalMetadata(sourceFormat: 'sav', compressionBias: $value), + ); + $adapter = new SpssAdapter($pdo, new FakeSpssEngine($source)); + $originalPrecision = ini_get('serialize_precision'); + try { + ini_set('serialize_precision', $precision); + if ($direct) { + $adapter->migrateCatalog(); + $normalized = SpssSourceNormalizer::normalize($source); + $normalized['multipleResponseSets'][0]['countedValue'] = (float) $counted; + (new SqliteWideTableImporter($pdo))->import($normalized, 'precision', 'fixture.sav'); + } else { + $adapter->import('fixture.sav', 'precision'); + } + } finally { + ini_set('serialize_precision', $originalPrecision); + } + + $rows = self::rows($pdo, 'SELECT * FROM dataset_precision ORDER BY __case_ordinal'); + self::assertSame([1, 2, 3], array_column($rows, '__case_ordinal')); + self::assertSame(['blue', '', 'green'], array_column($rows, 'favourite_colour')); + self::assertNull($rows[1]['respondent_id']); + self::assertSame(7.0, $rows[2]['respondent_id']); + $actual = ['case' => $rows[0]['respondent_id']]; + foreach ([ + 'compression bias' => 'SELECT compression_bias FROM file_technical_metadata', + 'legacy counted value' => 'SELECT counted_numeric_value FROM multiple_response_sets', + 'normative counted value' => 'SELECT counted_numeric_value FROM multiple_response_set', + ] as $field => $sql) { + $actual[$field] = array_values(self::rows($pdo, $sql)[0])[0]; + } + self::assertSame( + array_map(static fn($number): string => bin2hex(pack('E', $number)), [ + 'case' => $value, 'compression bias' => $value, + 'legacy counted value' => $counted, 'normative counted value' => $counted, + ]), + array_map(static fn($number): string => bin2hex(pack('E', $number)), $actual), + ); + self::assertFalse($pdo->inTransaction()); + } + + /** @return iterable */ + public static function nonExceptionImportModes(): iterable + { + foreach (['silent' => PDO::ERRMODE_SILENT, 'warning' => PDO::ERRMODE_WARNING] as $name => $mode) { + foreach (self::importEntryPoints() as $entry => [$direct]) { + yield $name . ' ' . $entry => [$mode, $direct]; + } + } + } + + #[DataProvider('nonExceptionImportModes')] + public function testImportCannotPublishPartialSuccessAndPreservesCallerErrorMode(int $mode, bool $direct): void + { + $pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => $mode]); + $source = $this->fixture(); + $adapter = new SpssAdapter($pdo, new FakeSpssEngine($source)); + $adapter->migrateCatalog(); + $import = static function (string $name) use ($pdo, $source, $adapter, $direct): void { + if ($direct) { + (new SqliteWideTableImporter($pdo))->import(SpssSourceNormalizer::normalize($source), $name, 'fixture.sav'); + } else { + $adapter->import('fixture.sav', $name); + } + }; + $import('prior'); + self::assertSame($mode, $pdo->getAttribute(PDO::ATTR_ERRMODE)); + self::assertFalse($pdo->inTransaction()); + self::assertCount(2, self::rows($pdo, 'SELECT * FROM dataset_prior')); + $pdo->exec("ALTER TABLE documents ADD COLUMN import_check INTEGER CONSTRAINT reject_document CHECK (dataset_name <> 'attempt' OR ordinal <> 2)"); + $before = []; + foreach (self::rows($pdo, "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT IN ('operation_catalog', 'operation', 'fidelity_event_catalog', 'fidelity_event')") as $table) { + $before[$table['name']] = self::rows($pdo, 'SELECT * FROM "' . $table['name'] . '"'); + } + + $failure = null; + try { + $import('attempt'); + } catch (\PDOException $exception) { + $failure = $exception; + } + self::assertSame($mode, $pdo->getAttribute(PDO::ATTR_ERRMODE)); + self::assertFalse($pdo->inTransaction()); + self::assertNotNull($failure, 'A rejected document was silently committed as a successful import.'); + self::assertStringContainsString('reject_document', $failure->getMessage()); + self::assertSame([], self::rows($pdo, "SELECT name FROM sqlite_master WHERE name = 'dataset_attempt'")); + foreach ($before as $table => $rows) { + self::assertSame($rows, self::rows($pdo, 'SELECT * FROM "' . $table . '"'), $table); + } + if (!$direct) { + self::assertSame([['status' => 'failed', 'dataset_name' => null]], self::rows($pdo, "SELECT status, dataset_name FROM operation_catalog WHERE status <> 'succeeded'")); + } + } + + /** @return iterable */ + public static function finalizationFailures(): iterable + { + yield 'hook throws' => [false]; + yield 'normative success update fails' => [true]; + } + + #[DataProvider('finalizationFailures')] + public function testImportFinalizationFailureRollsBackOnlyThisAttempt(bool $journalFailure): void + { + $pdo = new PDO('sqlite::memory:'); + $engine = new FakeSpssEngine($this->fixture()); + $prior = (new SpssAdapter($pdo, $engine))->import('prior.sav', 'prior'); + $before = []; + foreach (self::rows($pdo, "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT IN ('operation_catalog', 'operation', 'fidelity_event_catalog', 'fidelity_event')") as $table) { + $before[$table['name']] = self::rows($pdo, 'SELECT * FROM "' . $table['name'] . '"'); + } + $inTransaction = false; + $injected = new RuntimeException('Injected finalization failure.'); + $adapter = new SpssAdapter($pdo, $engine, beforeImportFinalization: static function () use ($pdo, $prior, $journalFailure, $injected, &$inTransaction): void { + $inTransaction = $pdo->inTransaction(); + if (!$journalFailure) { + throw $injected; + } + $pdo->exec("ALTER TABLE operation ADD COLUMN import_check INTEGER CONSTRAINT reject_success CHECK (status <> 'succeeded' OR operation_id = '{$prior->operationId}')"); + }); + try { + $adapter->import('attempt.sav', 'attempt'); + self::fail('Finalization failure was swallowed.'); + } catch (RuntimeException $exception) { + if ($journalFailure) { + self::assertInstanceOf(\PDOException::class, $exception); + self::assertStringContainsString('reject_success', $exception->getMessage()); + } else { + self::assertSame($injected, $exception); + } + } + self::assertFalse($pdo->inTransaction()); + self::assertSame([], self::rows($pdo, "SELECT name FROM sqlite_master WHERE name = 'dataset_attempt'")); + foreach ($before as $table => $rows) { + self::assertSame($rows, self::rows($pdo, 'SELECT * FROM "' . $table . '"'), $table); + } + self::assertTrue($inTransaction, 'Finalization must share the dataset transaction.'); + self::assertSame([ + ['target_path' => 'attempt.sav', 'status' => 'failed', 'dataset_name' => null, 'normative_status' => 'failed'], + ['target_path' => 'prior.sav', 'status' => 'succeeded', 'dataset_name' => 'prior', 'normative_status' => 'succeeded'], + ], self::rows($pdo, 'SELECT target_path, legacy.status, dataset_name, normative.status AS normative_status FROM operation_catalog legacy JOIN operation normative USING (operation_id) ORDER BY target_path')); + self::assertSame([['dataset_name' => null, 'code' => 'operation_failed']], self::rows($pdo, 'SELECT dataset_name, code FROM fidelity_event_catalog')); + self::assertSame([['dataset_id' => null, 'event_code' => 'operation_failed']], self::rows($pdo, 'SELECT dataset_id, event_code FROM fidelity_event')); + } + + #[DataProvider('importEntryPoints')] + public function testImportRejectsCallerOwnedTransactionWithoutMutation(bool $direct): void + { + $pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT]); + $source = $this->fixture(); + $adapter = new SpssAdapter($pdo, new FakeSpssEngine($source)); + $adapter->import('prior.sav', 'prior'); + $pdo->beginTransaction(); + $pdo->exec("UPDATE dataset_prior SET favourite_colour = 'pending' WHERE __case_ordinal = 1"); + $before = self::rows($pdo, 'SELECT * FROM sqlite_master'); + $changes = self::rows($pdo, 'SELECT total_changes() AS changes'); + $failure = null; + try { + if ($direct) { + (new SqliteWideTableImporter($pdo))->import(SpssSourceNormalizer::normalize($source), 'attempt', 'fixture.sav'); + } else { + $adapter->import('fixture.sav', 'attempt'); + } + } catch (\Throwable $exception) { + $failure = $exception; + } + self::assertTrue($pdo->inTransaction()); + self::assertSame(PDO::ERRMODE_SILENT, $pdo->getAttribute(PDO::ATTR_ERRMODE)); + self::assertSame([['favourite_colour' => 'pending']], self::rows($pdo, 'SELECT favourite_colour FROM dataset_prior WHERE __case_ordinal = 1')); + self::assertSame($before, self::rows($pdo, 'SELECT * FROM sqlite_master')); + self::assertSame($changes, self::rows($pdo, 'SELECT total_changes() AS changes')); + $pdo->rollBack(); + self::assertSame([['favourite_colour' => 'blue']], self::rows($pdo, 'SELECT favourite_colour FROM dataset_prior WHERE __case_ordinal = 1')); + self::assertInstanceOf(UnsupportedOperation::class, $failure); + self::assertSame(DiagnosticCode::UnsupportedOperation, $failure->diagnosticCode); + } + private function fixture(string $sourceFormat = 'zsav'): Dataset { return new Dataset( diff --git a/tests/Sql/MySqlWideTableImporterTest.php b/tests/Sql/MySqlWideTableImporterTest.php index 7f3c9d6..2f25987 100644 --- a/tests/Sql/MySqlWideTableImporterTest.php +++ b/tests/Sql/MySqlWideTableImporterTest.php @@ -17,9 +17,34 @@ final class MySqlWideTableImporterTest extends TestCase { + public function testRejectsCallerOwnedTransactionBeforeMutation(): void + { + $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->willReturn(true); + $pdo->method('inTransaction')->willReturn(true); + $pdo->expects(self::never())->method('beginTransaction'); + $pdo->expects(self::never())->method('commit'); + $pdo->expects(self::never())->method('rollBack'); + $pdo->expects(self::never())->method('exec'); + $pdo->expects(self::never())->method('prepare'); + + try { + (new MySqlWideTableImporter($pdo))->import([ + 'variables' => [['name' => 'Score', 'type' => 'numeric']], + 'data' => [[1.0]], + ], 'attempt'); + self::fail('Caller-owned transaction was accepted.'); + } catch (UnsupportedOperation $exception) { + self::assertSame(DiagnosticCode::UnsupportedOperation, $exception->diagnosticCode); + } + } + public function testImportsCatalogueAndOrderedRowsAfterMysqlDdl(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $dataset = $this->createMock(PDOStatement::class); $variables = $this->createMock(PDOStatement::class); $cases = $this->createMock(PDOStatement::class); @@ -66,6 +91,8 @@ public function testImportsCatalogueAndOrderedRowsAfterMysqlDdl(): void public function testImportsCoreSpssMetadataThroughMysqlCatalogue(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $fileLabel = $this->createMock(PDOStatement::class); $documents = $this->createMock(PDOStatement::class); $technical = $this->createMock(PDOStatement::class); @@ -167,6 +194,8 @@ public function testImportsCoreSpssMetadataThroughMysqlCatalogue(): void public function testImportsV3ExtensionMetadataAndPreservesOrderedMembers(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $dataset = $this->createMock(PDOStatement::class); $variables = $this->createMock(PDOStatement::class); $roles = $this->createMock(PDOStatement::class); @@ -260,7 +289,7 @@ public function testImportsV3ExtensionMetadataAndPreservesOrderedMembers(): void ['customer survey', 1, 2, 1], ], $variableSetMemberRows); self::assertSame([ - ['customer survey', 1, '$Profile', 'dichotomy', 'Profile', 'numeric', 1.0, null, 'counted_values', 'variable_label'], + ['customer survey', 1, '$Profile', 'dichotomy', 'Profile', 'numeric', '1.0', null, 'counted_values', 'variable_label'], ], $multipleResponseSetRows); self::assertSame([ ['customer survey', 1, 1, 2], @@ -271,6 +300,8 @@ public function testImportsV3ExtensionMetadataAndPreservesOrderedMembers(): void public function testInjectedDoltProfileRejects306VariablesBeforeDdl(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $pdo->expects(self::never())->method('exec'); $pdo->expects(self::never())->method('prepare'); $variables = []; @@ -292,6 +323,8 @@ public function testInjectedDoltProfileRejects306VariablesBeforeDdl(): void public function testRolledBackCaseInsertFailureDropsOnlyAttemptPhysicalTable(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $dataset = $this->createMock(PDOStatement::class); $variables = $this->createMock(PDOStatement::class); $cases = $this->createMock(PDOStatement::class); @@ -303,7 +336,7 @@ public function testRolledBackCaseInsertFailureDropsOnlyAttemptPhysicalTable(): return 0; }); $pdo->expects(self::once())->method('beginTransaction')->willReturn(true); - $pdo->expects(self::once())->method('inTransaction')->willReturn(true); + $pdo->expects(self::exactly(2))->method('inTransaction')->willReturnOnConsecutiveCalls(false, true); $pdo->expects(self::once())->method('rollBack')->willReturn(true); $pdo->expects(self::never())->method('commit'); $pdo->expects(self::exactly(3))->method('prepare')->willReturnOnConsecutiveCalls( @@ -454,6 +487,8 @@ public static function nonFiniteValues(): iterable private function doltPreflightPdo(): PDO { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $statement = $this->createMock(PDOStatement::class); $pdo->method('query')->with('SELECT @@max_allowed_packet')->willReturn($statement); $statement->method('fetchColumn')->willReturn('1073741824'); diff --git a/tests/Sql/PostgreSqlWideTableImporterTest.php b/tests/Sql/PostgreSqlWideTableImporterTest.php index 465264f..67a0bc8 100644 --- a/tests/Sql/PostgreSqlWideTableImporterTest.php +++ b/tests/Sql/PostgreSqlWideTableImporterTest.php @@ -4,16 +4,52 @@ namespace OpenStatSpec\Tests\Sql; +use OpenStatSpec\Core\DiagnosticCode; +use OpenStatSpec\Core\UnsupportedOperation; use OpenStatSpec\Sql\PostgreSqlWideTableImporter; use PDO; use PDOStatement; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class PostgreSqlWideTableImporterTest extends TestCase { + /** @return iterable */ + public static function transactionEntryPoints(): iterable + { + yield 'import' => ['import']; + yield 'create tables' => ['createTables']; + } + + #[DataProvider('transactionEntryPoints')] + public function testRejectsCallerOwnedTransactionBeforeMutation(string $entryPoint): void + { + $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->willReturn(true); + $pdo->method('inTransaction')->willReturn(true); + $pdo->expects(self::never())->method('beginTransaction'); + $pdo->expects(self::never())->method('commit'); + $pdo->expects(self::never())->method('rollBack'); + $pdo->expects(self::never())->method('exec'); + $pdo->expects(self::never())->method('prepare'); + + try { + (new PostgreSqlWideTableImporter($pdo))->$entryPoint([ + 'variables' => [['name' => 'Score', 'type' => 'numeric']], + 'data' => [[1.0]], + ], 'attempt'); + self::fail('Caller-owned transaction was accepted.'); + } catch (UnsupportedOperation $exception) { + self::assertSame(DiagnosticCode::UnsupportedOperation, $exception->diagnosticCode); + } + } + public function testCreatesCatalogAndStrictWideTableInOneTransaction(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $pdo->expects(self::once())->method('beginTransaction')->willReturn(true); $pdo->expects(self::once())->method('commit')->willReturn(true); $pdo->expects(self::never())->method('rollBack'); @@ -37,6 +73,8 @@ public function testCreatesCatalogAndStrictWideTableInOneTransaction(): void public function testImportsCatalogueAndOrderedRowsThroughPdoTransaction(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $dataset = $this->createMock(PDOStatement::class); $variables = $this->createMock(PDOStatement::class); $cases = $this->createMock(PDOStatement::class); @@ -81,6 +119,8 @@ public function testImportsCatalogueAndOrderedRowsThroughPdoTransaction(): void public function testImportsFileLabelDocumentsAndTechnicalMetadata(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $fileLabel = $this->createMock(PDOStatement::class); $documents = $this->createMock(PDOStatement::class); $technical = $this->createMock(PDOStatement::class); @@ -137,6 +177,8 @@ public function testImportsFileLabelDocumentsAndTechnicalMetadata(): void public function testImportsValueLabelsAndOrderedUserMissingRulesThroughPdoTransaction(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $dataset = $this->createMock(PDOStatement::class); $variables = $this->createMock(PDOStatement::class); $missing = $this->createMock(PDOStatement::class); @@ -195,6 +237,8 @@ public function testImportsValueLabelsAndOrderedUserMissingRulesThroughPdoTransa public function testImportsVariableDisplayMetadataThroughPdoTransaction(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $dataset = $this->createMock(PDOStatement::class); $variables = $this->createMock(PDOStatement::class); $display = $this->createMock(PDOStatement::class); @@ -236,9 +280,11 @@ public function testImportsVariableDisplayMetadataThroughPdoTransaction(): void public function testRejectsNullStringBeforeCommitAndRollsBack(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $pdo->expects(self::never())->method('beginTransaction'); $pdo->expects(self::never())->method('commit'); - $pdo->expects(self::never())->method('inTransaction'); + $pdo->expects(self::once())->method('inTransaction')->willReturn(false); $pdo->expects(self::never())->method('rollBack'); $pdo->expects(self::never())->method('exec'); $pdo->expects(self::never())->method('prepare'); @@ -253,6 +299,8 @@ public function testRejectsNullStringBeforeCommitAndRollsBack(): void public function testImportsV3AttributesAndOrderedSetMembers(): void { $pdo = $this->createMock(PDO::class); + $pdo->method('getAttribute')->with(PDO::ATTR_ERRMODE)->willReturn(PDO::ERRMODE_EXCEPTION); + $pdo->method('setAttribute')->with(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)->willReturn(true); $dataset = $this->createMock(PDOStatement::class); $variables = $this->createMock(PDOStatement::class); $roles = $this->createMock(PDOStatement::class); @@ -340,7 +388,7 @@ public function testImportsV3AttributesAndOrderedSetMembers(): void ['customer survey', 1, 2, 2], ], $setMemberRows); self::assertSame([ - ['customer survey', 1, '$Profile', 'dichotomy', 'Profile', 'numeric', 1.0, null, 'counted_values', 'variable_label'], + ['customer survey', 1, '$Profile', 'dichotomy', 'Profile', 'numeric', '1.0', null, 'counted_values', 'variable_label'], ], $multipleResponseSetRows); }