From 804bc10ad150760e1697b1936c17b284c318b32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 7 Sep 2026 17:12:06 +0300 Subject: [PATCH 1/4] Make SAV and ZSAV export database-read-only --- README.md | 33 +- docs/architecture.md | 18 +- src/Spss/SpssAdapter.php | 53 ++-- src/Spss/SpssExportResult.php | 1 - src/Sql/CanonicalCatalogProjection.php | 269 ---------------- src/Sql/CanonicalWideTableExporter.php | 309 +++++++++++++++++++ src/Sql/CatalogOwnership.php | 31 +- tests/Integration/DoltReadOnlyExportTest.php | 155 ++++++++++ tests/Spss/SpssAdapterTest.php | 114 ++++++- tests/Support/FakeSpssEngine.php | 5 + 10 files changed, 657 insertions(+), 331 deletions(-) delete mode 100644 src/Sql/CanonicalCatalogProjection.php create mode 100644 src/Sql/CanonicalWideTableExporter.php create mode 100644 tests/Integration/DoltReadOnlyExportTest.php diff --git a/README.md b/README.md index f87d927..77ad70c 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,22 @@ $import = $adapter->import('/data/survey.zsav', 'survey_2026'); // SpssImportResult: operationId, datasetName, caseCount, diagnostics $export = $adapter->export('survey_2026', '/data/survey-export.sav'); -// SpssExportResult: operationId, datasetName, caseCount, diagnostics, allowLoss +// SpssExportResult: datasetName, targetPath, caseCount, diagnostics, allowLoss ``` +Export needs only read access to an already migrated catalogue and its wide +tables. It reads authoritative normative metadata directly and never initializes, +migrates, synchronizes compatibility tables, or writes database audit records—even +on failure. `SpssExportResult` no longer has `operationId`; export creates no +`operation_id`. Diagnostics and accepted loss codes remain in the result. +The destination is replaced only after successful writing to a temporary file in +its directory; a writer failure preserves an existing destination. Injected +engines receive that temporary path and must use the Dataset's target format. + +Server identity is still checked for reads, but export does not require a packaged +write-version claim or any external support declaration. Import, migration and +transformation write safeguards and tested server versions are unchanged. + Use `GuardedImportSpssEngine` when an engine must read from an ephemeral descriptor while the adapter and database receive only a logical source path: @@ -110,7 +123,7 @@ $export = $adapter->export( ); ``` -Pass only loss codes consciously accepted for that conversion. `operation_catalog` records successful and failed imports/exports; `fidelity_event_catalog` records emitted diagnostics. A failed preflight is therefore auditable even when it created no dataset. Each operation also records the selected SPSS engine package and Composer version in engine_details. +Pass only loss codes consciously accepted for that conversion. `operation_catalog` records journaled imports; `fidelity_event_catalog` records their emitted diagnostics. Import preflight failures after catalogue readiness are auditable even when no dataset was created. Each journaled import also records the selected SPSS engine package and Composer version in `engine_details`. Export returns diagnostics or throws without recording an operation. ## Transformation API @@ -243,7 +256,21 @@ MySQL 8.4.11/9.7.2, MariaDB 11.4.12/11.8.8/12.3.2, and Dolt Those checks use their PDO drivers and php-spss V3 read/write paths, not only DDL snapshots. Family policies remain runtime claims and exact patches are CI evidence points; Dolt's 2.2.x family claim additionally has an explicit 2.2.2 -minimum and 2.3.0 exclusive upper bound. +minimum and 2.3.0 exclusive upper bound for writes. + +Optional SELECT-only export coverage (also usable on Dolt 2.3.0) creates and +removes its own unique test database and user. It checks SAV/ZSAV output, failure +safety and unchanged working/staged roots and history, without expanding write +support: + +```bash +OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_DSN='mysql:host=127.0.0.1;port=13387;charset=utf8mb4' \ + vendor/bin/phpunit tests/Integration/DoltReadOnlyExportTest.php +``` + +The test defaults to `root` with an empty password; optional +`OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_USER` and +`OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_PASSWORD` override these test credentials. ## Contributing diff --git a/docs/architecture.md b/docs/architecture.md index 8215f4d..1e4fe4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,9 +50,13 @@ The catalogue is the semantic dictionary for the physical data table. It records - typed ordered value labels and all supported user-missing forms; - documents and technical file metadata; - file/variable attributes, variable sets, multiple-response sets and roles; and -- import/export operation records and fidelity events. +- import operation records and fidelity events. -`SpssImportResult` carries an operation ID, dataset name, case count and diagnostics. `SpssExportResult` carries the same operational evidence plus explicitly accepted loss codes. +`SpssImportResult` carries an operation ID, dataset name, case count and diagnostics. `SpssExportResult` carries dataset name, target path, case count, diagnostics and explicitly accepted loss codes, but no operation ID. + +Export reads the singular normative catalogue and existing physical wide table directly. The compatibility catalogue is not synchronized or used as an alternative semantic dictionary; only writer-supported technical provenance fields without normative counterparts are read from `file_technical_metadata`. No export path writes database state, including validation failures, missing catalogues and writer failures. Reads identify the server without requiring a packaged write-version claim. Write safeguards and the tested version matrix remain unchanged. + +The writer receives a temporary filesystem path in the destination directory and the target format in the typed Dataset. Only a successful write is published by rename; failure leaves an existing destination intact and removes temporary output. No database staging, copies, temporary tables or audit entries are used. Export does not manage a read transaction; callers needing a consistent snapshot during concurrent edits must supply one. ## Round trip and fidelity policy @@ -60,7 +64,7 @@ The objective is semantic equivalence for represented features, not byte identit An exporter may emit machine-readable `FidelityDiagnostic` values for a known loss boundary. Export is fail-closed: it refuses to write until each emitted diagnostic code is present in the caller's `allowLoss` list. That makes intentional lossy conversion explicit rather than silently producing a downgraded file. -The singular OpenStatSpec `operation` and `fidelity_event` tables are the authoritative audit record. Legacy `operation_catalog` and `fidelity_event_catalog` remain mirrored only for transition compatibility. A failed preflight creates a failed `operation` and at least one `fidelity_event` with a null dataset reference, direction, event code, source item and timestamp. +The singular OpenStatSpec `operation` and `fidelity_event` tables are the authoritative audit record. Legacy `operation_catalog` and `fidelity_event_catalog` remain mirrored only for transition compatibility. An import preflight failure after catalogue readiness creates a failed `operation` and at least one `fidelity_event` with a null dataset reference, direction, event code, source item and timestamp. Export never writes these audit tables. ## SAV/ZSAV boundary @@ -120,9 +124,9 @@ This package requires neither Yii2 nor Laravel. Applications supply a PDO connec Every normal import writes the singular OpenStatSpec catalogue tables from the normative schema: `dataset`, `variable`, value-label and missing-rule tables, attributes, `document`, variable/multiple-response sets, `operation`, and -`fidelity_event`. The older plural tables are a compatibility read model for -existing exports; they are not the standard contract for a newly imported -dataset. +`fidelity_event`. The older plural tables remain for compatibility and explicit +legacy migration; they are not the standard contract for a newly imported +dataset or the semantic source for public exports. Before upgrading an existing database explicitly, run: @@ -138,7 +142,7 @@ each exportable legacy dataset into the singular standard tables and provisions the compact official transformation-apply audit relation. A completely empty dedicated namespace is initialized automatically on its -first ordinary import or export attempt. An existing catalogue is never +first ordinary import attempt. Export never initializes even an empty namespace. An existing catalogue is never upgraded implicitly: when its identity or validated pre-identity state is older, ordinary use fails with `catalog_migration_required` before journal or schema mutation, and deployment must call `migrateCatalog()` explicitly. diff --git a/src/Spss/SpssAdapter.php b/src/Spss/SpssAdapter.php index c0b71e3..bb12480 100644 --- a/src/Spss/SpssAdapter.php +++ b/src/Spss/SpssAdapter.php @@ -8,7 +8,7 @@ use OpenStatSpec\Core\DiagnosticCode; use OpenStatSpec\Core\FidelityPolicy; use OpenStatSpec\Core\UnsupportedOperation; -use OpenStatSpec\Sql\CanonicalCatalogProjection; +use OpenStatSpec\Sql\CanonicalWideTableExporter; use OpenStatSpec\Sql\CatalogOwnership; use OpenStatSpec\Sql\Connection; use OpenStatSpec\Sql\MySqlWideTableDefinition; @@ -210,35 +210,34 @@ private function assertLogicalSourcePath(string $sourcePath): void /** @param list $allowLoss */ public function export(string $datasetName, string $targetPath, array $allowLoss = []): SpssExportResult { - $this->connection->assertClaimedSupported(); - $this->ensureCatalogReady(); - $targetFormat = $this->spssFormat($targetPath); - $journal = new OperationJournal($this->connection->pdo); - $operationId = $journal->start('export', $datasetName, $targetPath, $allowLoss, $this->engine->identity(), $targetFormat); - $diagnostics = []; + CatalogOwnership::assertReadyForUseReadOnly($this->connection->pdo); + $export = (new CanonicalWideTableExporter($this->connection->pdo))->export($datasetName, $this->spssFormat($targetPath)); + $diagnostics = $export['diagnostics']; + FidelityPolicy::assertExportAllowed($diagnostics, $allowLoss); + + $directory = realpath(dirname($targetPath)); + if ($directory === false || !is_dir($directory) || !is_writable($directory)) { + throw new \RuntimeException('The export destination directory is not writable.'); + } + $temporaryPath = tempnam($directory, '.openstatspec-export-'); + if ($temporaryPath === false) { + throw new \RuntimeException('Could not create a temporary export file.'); + } try { - if (!in_array($targetFormat, ['sav', 'zsav'], true)) { - throw new UnsupportedOperation( - DiagnosticCode::UnsupportedSourceFormat, - 'This adapter profile exports SAV and ZSAV files only.', - ); + if (dirname($temporaryPath) !== $directory) { + throw new \RuntimeException('The temporary export file must be in the destination directory.'); + } + $this->engine->write($temporaryPath, $export['dataset']); + if (!rename($temporaryPath, $directory . DIRECTORY_SEPARATOR . basename($targetPath))) { + throw new \RuntimeException('Could not publish the exported file.'); + } + } finally { + if (is_file($temporaryPath)) { + unlink($temporaryPath); } - (new CanonicalCatalogProjection($this->connection->pdo))->synchronize($datasetName); - $export = match ($this->connection->profile->driverName()) { - 'pgsql' => (new PostgreSqlWideTableExporter($this->connection->pdo))->export($datasetName, $targetFormat), - 'mysql' => (new MySqlWideTableExporter($this->connection->pdo))->export($datasetName, $targetFormat), - default => (new SqliteWideTableExporter($this->connection->pdo))->export($datasetName, $targetFormat), - }; - $diagnostics = $export['diagnostics']; - FidelityPolicy::assertExportAllowed($diagnostics, $allowLoss); - $this->engine->write($targetPath, $export['dataset']); - $journal->succeed($operationId, $datasetName, $diagnostics); - - return new SpssExportResult($operationId, $datasetName, $targetPath, $export['caseCount'], $diagnostics, $allowLoss); - } catch (Throwable $exception) { - $journal->fail($operationId, $datasetName, $exception, $diagnostics, $targetPath); - throw $exception; } + + return new SpssExportResult($datasetName, $targetPath, $export['caseCount'], $diagnostics, $allowLoss); } private function spssFormat(string $path): string diff --git a/src/Spss/SpssExportResult.php b/src/Spss/SpssExportResult.php index 862d5dd..de4ff8d 100644 --- a/src/Spss/SpssExportResult.php +++ b/src/Spss/SpssExportResult.php @@ -15,7 +15,6 @@ { /** @param list $diagnostics */ public function __construct( - public string $operationId, public string $datasetName, public string $targetPath, public int $caseCount, diff --git a/src/Sql/CanonicalCatalogProjection.php b/src/Sql/CanonicalCatalogProjection.php deleted file mode 100644 index 92f235c..0000000 --- a/src/Sql/CanonicalCatalogProjection.php +++ /dev/null @@ -1,269 +0,0 @@ -pdo))->createTables(); - $dataset = $this->one('SELECT dataset_id, source_format, physical_table_name, dataset_label, source_encoding FROM dataset WHERE dataset_name = ?', [$datasetName]); - $datasetId = $this->requiredString($dataset['dataset_id'] ?? null, 'Canonical dataset ID'); - $tableName = $this->requiredString($dataset['physical_table_name'] ?? null, 'Canonical physical table name'); - - $this->pdo->beginTransaction(); - try { - $this->clearLegacyMetadata($datasetName); - $this->ensureLegacyDataset($datasetName, $tableName); - $variables = $this->projectVariables($datasetId, $datasetName); - $this->projectDatasetMetadata($datasetId, $datasetName, $dataset); - $this->projectWeight($datasetId, $datasetName); - $this->projectValueLabels($datasetId, $datasetName); - $this->projectMissingRules($datasetId, $datasetName); - $this->projectAttributes($datasetId, $datasetName); - $this->projectDocuments($datasetId, $datasetName); - $this->projectSets($datasetId, $datasetName); - $this->projectMultipleResponseSets($datasetId, $datasetName); - if ($variables === 0) { - throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'The canonical dataset has no variables.'); - } - $this->pdo->commit(); - } catch (Throwable $exception) { - if ($this->pdo->inTransaction()) { - $this->pdo->rollBack(); - } - throw $exception; - } - } - - private function clearLegacyMetadata(string $datasetName): void - { - foreach ([ - 'multiple_response_set_members', 'multiple_response_sets', - 'variable_set_members', 'variable_sets', 'variable_attributes', - 'file_attributes', 'variable_roles', 'variable_display_metadata', - 'missing_rule_values', 'missing_rules', 'value_labels', 'documents', - 'dataset_metadata', 'dataset_weight_variables', - 'variables', - ] as $table) { - $this->statement('DELETE FROM ' . $table . ' WHERE dataset_name = ?')->execute([$datasetName]); - } - } - - private function ensureLegacyDataset(string $datasetName, string $tableName): void - { - $exists = $this->statement('SELECT 1 FROM datasets WHERE dataset_name = ?'); - $exists->execute([$datasetName]); - if ($exists->fetchColumn() === false) { - $this->statement('INSERT INTO datasets (dataset_name, table_name) VALUES (?, ?)')->execute([$datasetName, $tableName]); - return; - } - $this->statement('UPDATE datasets SET table_name = ? WHERE dataset_name = ?')->execute([$tableName, $datasetName]); - } - - private function projectVariables(string $datasetId, string $datasetName): int - { - $rows = $this->all('SELECT source_ordinal, source_name, physical_name, storage_kind, declared_string_width, variable_label, print_format_family, print_format_width, print_format_decimals, write_format_family, write_format_width, write_format_decimals, measurement_level, variable_role, display_width, display_alignment FROM variable WHERE dataset_id = ? ORDER BY source_ordinal', [$datasetId]); - $variable = $this->statement('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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); - $display = $this->statement('INSERT INTO variable_display_metadata (dataset_name, variable_ordinal, measurement_level, display_width, alignment) VALUES (?, ?, ?, ?, ?)'); - $role = $this->statement('INSERT INTO variable_roles (dataset_name, variable_ordinal, role) VALUES (?, ?, ?)'); - foreach ($rows as $row) { - $ordinal = (int) $row['source_ordinal']; - $variable->execute([ - $datasetName, $ordinal, $row['source_name'], $row['physical_name'], $row['storage_kind'], - $row['declared_string_width'] === null ? 0 : (int) $row['declared_string_width'], - (int) $row['print_format_family'], (int) $row['print_format_width'], (int) $row['print_format_decimals'], - (int) $row['write_format_family'], (int) $row['write_format_width'], (int) $row['write_format_decimals'], - $row['variable_label'], - ]); - $display->execute([$datasetName, $ordinal, (int) ($row['measurement_level'] ?? 0), (int) ($row['display_width'] ?? 8), (int) ($row['display_alignment'] ?? 0)]); - $role->execute([$datasetName, $ordinal, (int) ($row['variable_role'] ?? 0)]); - } - return count($rows); - } - - /** @param array $dataset */ - private function projectDatasetMetadata(string $datasetId, string $datasetName, array $dataset): void - { - if (is_string($dataset['dataset_label'] ?? null)) { - $this->statement('INSERT INTO dataset_metadata (dataset_name, meta_key, meta_value) VALUES (?, ?, ?)')->execute([$datasetName, 'file_label', $dataset['dataset_label']]); - } - $format = $this->requiredString($dataset['source_format'] ?? null, 'Canonical source format'); - $encoding = is_string($dataset['source_encoding'] ?? null) && $dataset['source_encoding'] !== '' ? $dataset['source_encoding'] : 'UTF-8'; - $technical = $this->statement('SELECT 1 FROM file_technical_metadata WHERE dataset_name = ?'); - $technical->execute([$datasetName]); - if ($technical->fetchColumn() !== false) { - $this->statement('UPDATE file_technical_metadata SET source_format = ?, record_type = ?, encoding = ?, compression = ? WHERE dataset_name = ?')->execute([$format, $format === 'zsav' ? '$FL3' : '$FL2', $encoding, $format === 'zsav' ? 2 : 1, $datasetName]); - } else { - $this->statement('INSERT INTO file_technical_metadata (dataset_name, source_format, record_type, source_version, provenance, encoding, product_name, raw_creation_date, raw_creation_time, case_count, nominal_case_size, layout_code, compression, compression_bias, machine_code, floating_point_representation, endianness, character_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')->execute([ - $datasetName, $format, $format === 'zsav' ? '$FL3' : '$FL2', null, null, $encoding, null, null, null, null, null, null, $format === 'zsav' ? 2 : 1, null, null, null, null, null, - ]); - } - } - - private function projectWeight(string $datasetId, string $datasetName): void - { - $row = $this->optionalOne('SELECT variable.source_ordinal FROM dataset_weight_variable weight JOIN variable ON variable.variable_id = weight.variable_id WHERE weight.dataset_id = ?', [$datasetId]); - if ($row !== null) { - $this->statement('INSERT INTO dataset_weight_variables (dataset_name, variable_ordinal) VALUES (?, ?)')->execute([$datasetName, (int) $row['source_ordinal']]); - } - } - - private function projectValueLabels(string $datasetId, string $datasetName): void - { - $rows = $this->all('SELECT variable.source_ordinal, label.ordinal, label.code_kind, label.numeric_code, label.string_code, label.label FROM variable JOIN variable_value_label_set link ON link.variable_id = variable.variable_id JOIN value_label label ON label.value_label_set_id = link.value_label_set_id WHERE variable.dataset_id = ? ORDER BY variable.source_ordinal, label.ordinal', [$datasetId]); - $insert = $this->statement('INSERT INTO value_labels (dataset_name, variable_ordinal, ordinal, value_kind, numeric_value, text_value, label) VALUES (?, ?, ?, ?, ?, ?, ?)'); - foreach ($rows as $row) { - $isString = $row['code_kind'] === 'string'; - $insert->execute([$datasetName, (int) $row['source_ordinal'], (int) $row['ordinal'], $isString ? 'text' : 'numeric', $isString ? null : $row['numeric_code'], $isString ? $row['string_code'] : null, $row['label']]); - } - } - - private function projectMissingRules(string $datasetId, string $datasetName): void - { - $rows = $this->all('SELECT variable.source_ordinal, rule.ordinal, rule.rule_kind, rule.code_kind, rule.numeric_value, rule.string_value, rule.numeric_lower, rule.numeric_upper, rule.lower_special, rule.upper_special FROM variable JOIN missing_rule rule ON rule.variable_id = variable.variable_id WHERE variable.dataset_id = ? ORDER BY variable.source_ordinal, rule.ordinal', [$datasetId]); - $grouped = []; - foreach ($rows as $row) { - $grouped[(int) $row['source_ordinal']][] = $row; - } - $ruleInsert = $this->statement('INSERT INTO missing_rules (dataset_name, variable_ordinal, missing_format) VALUES (?, ?, ?)'); - $valueInsert = $this->statement('INSERT INTO missing_rule_values (dataset_name, variable_ordinal, ordinal, value_kind, numeric_value, text_value) VALUES (?, ?, ?, ?, ?, ?)'); - foreach ($grouped as $variableOrdinal => $rules) { - $first = $rules[0]; - $range = $first['rule_kind'] === 'numeric_range'; - $format = $range ? (count($rules) > 1 ? -3 : -2) : count($rules); - $ruleInsert->execute([$datasetName, $variableOrdinal, $format]); - $values = []; - if ($range) { - $values[] = ['numeric', $first['lower_special'] === 'LOWEST' ? Binary64::encode(SpssMissingValueSentinel::lowest()) : $first['numeric_lower'], null]; - $values[] = ['numeric', $first['upper_special'] === 'HIGHEST' ? Binary64::encode(SpssMissingValueSentinel::highest()) : $first['numeric_upper'], null]; - $rules = array_slice($rules, 1); - } - foreach ($rules as $rule) { - $isString = $rule['code_kind'] === 'string'; - $values[] = [$isString ? 'text' : 'numeric', $isString ? null : $rule['numeric_value'], $isString ? $rule['string_value'] : null]; - } - foreach ($values as $index => $value) { - $valueInsert->execute([$datasetName, $variableOrdinal, $index + 1, $value[0], $value[1], $value[2]]); - } - } - } - - private function projectAttributes(string $datasetId, string $datasetName): void - { - $file = $this->statement('INSERT INTO file_attributes (dataset_name, attribute_name, ordinal, value) VALUES (?, ?, ?, ?)'); - foreach ($this->all('SELECT attribute_name, array_ordinal, attribute_value FROM dataset_attribute WHERE dataset_id = ? ORDER BY attribute_name, array_ordinal', [$datasetId]) as $row) { - $file->execute([$datasetName, $row['attribute_name'], (int) $row['array_ordinal'], $row['attribute_value']]); - } - $variable = $this->statement('INSERT INTO variable_attributes (dataset_name, variable_ordinal, attribute_name, ordinal, value) VALUES (?, ?, ?, ?, ?)'); - foreach ($this->all('SELECT v.source_ordinal, va.attribute_name, va.array_ordinal, va.attribute_value FROM variable_attribute va JOIN variable v ON v.variable_id = va.variable_id WHERE v.dataset_id = ? ORDER BY v.source_ordinal, va.attribute_name, va.array_ordinal', [$datasetId]) as $row) { - $variable->execute([$datasetName, (int) $row['source_ordinal'], $row['attribute_name'], (int) $row['array_ordinal'], $row['attribute_value']]); - } - } - - private function projectDocuments(string $datasetId, string $datasetName): void - { - $insert = $this->statement('INSERT INTO documents (dataset_name, ordinal, text) VALUES (?, ?, ?)'); - foreach ($this->all('SELECT source_ordinal, document_text FROM document WHERE dataset_id = ? ORDER BY source_ordinal', [$datasetId]) as $row) { - $insert->execute([$datasetName, (int) $row['source_ordinal'], $row['document_text']]); - } - } - - private function projectSets(string $datasetId, string $datasetName): void - { - $setInsert = $this->statement('INSERT INTO variable_sets (dataset_name, set_ordinal, name) VALUES (?, ?, ?)'); - $memberInsert = $this->statement('INSERT INTO variable_set_members (dataset_name, set_ordinal, member_ordinal, variable_ordinal) VALUES (?, ?, ?, ?)'); - foreach ($this->all('SELECT variable_set_id, source_ordinal, set_name FROM variable_set WHERE dataset_id = ? ORDER BY source_ordinal', [$datasetId]) as $set) { - $ordinal = (int) $set['source_ordinal']; - $setInsert->execute([$datasetName, $ordinal, $set['set_name']]); - foreach ($this->all('SELECT member.source_ordinal, variable.source_ordinal AS variable_ordinal FROM variable_set_member member JOIN variable ON variable.variable_id = member.variable_id WHERE member.variable_set_id = ? ORDER BY member.source_ordinal', [$set['variable_set_id']]) as $member) { - $memberInsert->execute([$datasetName, $ordinal, (int) $member['source_ordinal'], (int) $member['variable_ordinal']]); - } - } - } - - private function projectMultipleResponseSets(string $datasetId, string $datasetName): void - { - $setInsert = $this->statement('INSERT INTO multiple_response_sets (dataset_name, set_ordinal, name, set_type, label, counted_value_kind, counted_numeric_value, counted_text_value, category_labels, label_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); - $memberInsert = $this->statement('INSERT INTO multiple_response_set_members (dataset_name, set_ordinal, member_ordinal, variable_ordinal) VALUES (?, ?, ?, ?)'); - foreach ($this->all('SELECT multiple_response_set_id, source_ordinal, set_name, set_label, set_kind, counted_value_kind, counted_numeric_value, counted_string_value, category_label_behavior, label_source FROM multiple_response_set WHERE dataset_id = ? ORDER BY source_ordinal', [$datasetId]) as $set) { - $ordinal = (int) $set['source_ordinal']; - $kind = $set['counted_value_kind'] === 'string' ? 'text' : $set['counted_value_kind']; - $setInsert->execute([$datasetName, $ordinal, $set['set_name'], $set['set_kind'] === 'MD' ? 'dichotomy' : 'category', $set['set_label'], $kind, $set['counted_numeric_value'], $set['counted_string_value'], $set['category_label_behavior'], $set['label_source']]); - foreach ($this->all('SELECT member.source_ordinal, variable.source_ordinal AS variable_ordinal FROM multiple_response_member member JOIN variable ON variable.variable_id = member.variable_id WHERE member.multiple_response_set_id = ? ORDER BY member.source_ordinal', [$set['multiple_response_set_id']]) as $member) { - $memberInsert->execute([$datasetName, $ordinal, (int) $member['source_ordinal'], (int) $member['variable_ordinal']]); - } - } - } - - /** - * @param list $parameters - * @return array - */ - private function one(string $sql, array $parameters): array - { - $row = $this->optionalOne($sql, $parameters); - if ($row === null) { - throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'The requested canonical dataset is not present.'); - } - return $row; - } - - /** - * @param list $parameters - * @return array|null - */ - private function optionalOne(string $sql, array $parameters): ?array - { - $statement = $this->statement($sql); - $statement->execute($parameters); - $row = $statement->fetch(PDO::FETCH_ASSOC); - return is_array($row) ? $row : null; - } - - /** - * @param list $parameters - * @return list> - */ - private function all(string $sql, array $parameters): array - { - $statement = $this->statement($sql); - $statement->execute($parameters); - return array_values($statement->fetchAll(PDO::FETCH_ASSOC)); - } - - private function statement(string $sql): PDOStatement - { - $statement = $this->pdo->prepare($sql); - if ($statement === false) { - throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'Could not prepare a canonical catalog projection statement.'); - } - return $statement; - } - - private function requiredString(mixed $value, string $description): string - { - if (!is_string($value) || $value === '') { - throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, $description . ' is missing.'); - } - return $value; - } -} diff --git a/src/Sql/CanonicalWideTableExporter.php b/src/Sql/CanonicalWideTableExporter.php new file mode 100644 index 0000000..29a44a9 --- /dev/null +++ b/src/Sql/CanonicalWideTableExporter.php @@ -0,0 +1,309 @@ +} */ + public function export(string $datasetName, string $targetFormat = 'sav'): array + { + if (!in_array($targetFormat, ['sav', 'zsav'], true)) { + throw new UnsupportedOperation(DiagnosticCode::UnsupportedSourceFormat, 'Only SAV and ZSAV exports are supported.'); + } + $dataset = $this->all('SELECT * FROM dataset WHERE dataset_name = ?', [$datasetName])[0] ?? null; + if ($dataset === null) { + throw $this->invalid('The requested canonical dataset is not present.'); + } + $id = $this->string($dataset['dataset_id']); + $variables = $this->all('SELECT * FROM variable WHERE dataset_id = ? ORDER BY source_ordinal', [$id]); + if ($variables === []) { + throw $this->invalid('The canonical dataset has no variables.'); + } + $typedVariables = []; + $columns = []; + foreach ($variables as $variable) { + $name = $this->string($variable['source_name']); + $variableId = $this->string($variable['variable_id']); + $columns[] = $this->quote($this->string($variable['physical_name'])); + $type = match ($variable['storage_kind']) { + 'string' => VariableType::STRING, + 'numeric' => VariableType::NUMERIC, + default => throw $this->invalid('Invalid variable storage kind.'), + }; + $labels = []; + foreach ($this->all('SELECT label.* FROM variable_value_label_set link JOIN value_label label ON label.value_label_set_id = link.value_label_set_id WHERE link.variable_id = ? ORDER BY label.ordinal', [$variableId]) as $label) { + $labels[] = new ValueLabel($this->value($label['code_kind'], $label['numeric_code'], $label['string_code']), $this->text($label['label'])); + } + $attributes = []; + foreach ($this->attributes('SELECT attribute_name, attribute_value FROM variable_attribute WHERE variable_id = ? ORDER BY attribute_name, array_ordinal', $variableId) as $attributeName => $values) { + $attributes[] = new VariableAttribute($name, $attributeName, $values); + } + $typedVariables[] = new VariableMetadata( + name: $name, + type: $type, + width: $type === VariableType::STRING ? $this->integer($variable['declared_string_width']) : 0, + printFormat: $this->format($variable, 'print', $type), + writeFormat: $this->format($variable, 'write', $type), + label: $variable['variable_label'] === null ? null : $this->text($variable['variable_label']), + valueLabels: new ValueLabelSet($labels, [$name]), + missingValues: $this->missingValues($variableId), + measure: Measure::tryFrom($this->integer($variable['measurement_level'] ?? 0)) ?? Measure::UNKNOWN, + alignment: Alignment::tryFrom($this->integer($variable['display_alignment'] ?? 0)) ?? Alignment::LEFT, + columns: max(0, $this->integer($variable['display_width'] ?? 8)), + role: VariableRole::tryFrom($this->integer($variable['variable_role'] ?? 0)) ?? throw $this->invalid('Invalid variable role.'), + attributes: $attributes, + dictionaryIndex: $this->integer($variable['source_ordinal']), + ); + } + $table = $this->quote($this->string($dataset['physical_table_name'])); + if ($dataset['physical_table_schema'] !== null && $dataset['physical_table_schema'] !== '') { + $table = $this->quote($this->string($dataset['physical_table_schema'])) . '.' . $table; + } + $cases = $this->pdo->prepare('SELECT ' . implode(', ', $columns) . ' FROM ' . $table . ' ORDER BY ' . $this->quote('__case_ordinal')); + if ($cases === false || !$cases->execute()) { + throw $this->invalid('Could not read the physical wide table.'); + } + $rows = []; + while (($row = $cases->fetch(PDO::FETCH_ASSOC)) !== false) { + $values = []; + foreach ($variables as $variable) { + $value = $row[$this->string($variable['physical_name'])]; + $values[] = $variable['storage_kind'] === 'string' + ? $this->text($value) + : ($value === null ? null : $this->numeric($value)); + } + $rows[] = $values; + } + $attributes = []; + foreach ($this->attributes('SELECT attribute_name, attribute_value FROM dataset_attribute WHERE dataset_id = ? ORDER BY attribute_name, array_ordinal', $id) as $name => $values) { + $attributes[] = new FileAttribute($name, $values); + } + $weight = $this->all('SELECT variable.source_name FROM dataset_weight_variable weight LEFT JOIN variable ON variable.variable_id = weight.variable_id AND variable.dataset_id = weight.dataset_id WHERE weight.dataset_id = ?', [$id]); + $documents = []; + foreach ($this->all('SELECT document_text FROM document WHERE dataset_id = ? ORDER BY source_ordinal', [$id]) as $document) { + $documents[] = $this->text($document['document_text']); + } + // These writer-supported provenance fields have no normative counterpart. + $technical = $this->all('SELECT source_version, provenance, product_name FROM file_technical_metadata WHERE dataset_name = ?', [$datasetName])[0] ?? []; + $zsav = $targetFormat === 'zsav'; + return [ + 'dataset' => new Dataset( + new VariableDictionary($typedVariables), + $rows, + new FileMetadata( + label: $dataset['dataset_label'] === null ? null : $this->text($dataset['dataset_label']), + weightVariableName: $weight === [] ? null : $this->string($weight[0]['source_name']), + documents: $documents, + attributes: $attributes, + variableSets: $this->variableSets($id), + multipleResponseSets: $this->multipleResponseSets($id), + ), + new FileTechnicalMetadata( + sourceFormat: $targetFormat, + recordType: $zsav ? '$FL3' : '$FL2', + sourceVersion: $this->optionalText($technical['source_version'] ?? null), + provenance: $this->optionalText($technical['provenance'] ?? null), + encoding: $this->optionalText($dataset['source_encoding']) ?? 'UTF-8', + productName: $this->optionalText($technical['product_name'] ?? null), + compression: $zsav ? 2 : 1, + ), + ), + 'caseCount' => count($rows), + 'diagnostics' => [], + ]; + } + + /** @param array $variable */ + private function format(array $variable, string $prefix, VariableType $type): VariableFormat + { + $width = $this->integer($variable[$prefix . '_format_width']); + return new VariableFormat( + $this->integer($variable[$prefix . '_format_family']), + $type === VariableType::STRING ? min($width, 255) : $width, + $this->integer($variable[$prefix . '_format_decimals']), + ); + } + + private function missingValues(string $variableId): MissingValues + { + $rules = $this->all('SELECT * FROM missing_rule WHERE variable_id = ? ORDER BY ordinal', [$variableId]); + if ($rules === []) { + return MissingValues::none(); + } + $range = $rules[0]['rule_kind'] === 'numeric_range' ? array_shift($rules) : null; + $values = []; + foreach ($rules as $rule) { + if ($rule['rule_kind'] !== 'discrete') { + throw $this->invalid('Invalid discrete missing rule.'); + } + $values[] = $this->value($rule['code_kind'], $rule['numeric_value'], $rule['string_value']); + } + if ($range !== null) { + $lower = $range['lower_special'] === 'LOWEST' ? SpssMissingValueSentinel::lowest() : $this->numeric($range['numeric_lower']); + $upper = $range['upper_special'] === 'HIGHEST' ? SpssMissingValueSentinel::highest() : $this->numeric($range['numeric_upper']); + return match (count($values)) { + 0 => MissingValues::range($lower, $upper), + 1 => MissingValues::rangeAndValue($lower, $upper, $this->numeric($values[0])), + default => throw $this->invalid('A missing range permits at most one discrete value.'), + }; + } + return MissingValues::discrete(...$values); + } + + /** @return list */ + private function variableSets(string $datasetId): array + { + $sets = []; + foreach ($this->all('SELECT variable_set_id, set_name FROM variable_set WHERE dataset_id = ? ORDER BY source_ordinal', [$datasetId]) as $set) { + $members = $this->all('SELECT variable.source_name FROM variable_set_member member LEFT JOIN variable ON variable.variable_id = member.variable_id AND variable.dataset_id = ? WHERE member.variable_set_id = ? ORDER BY member.source_ordinal', [$datasetId, $set['variable_set_id']]); + $sets[] = new VariableSet($this->string($set['set_name']), array_map(fn(array $member): string => $this->string($member['source_name']), $members)); + } + return $sets; + } + + /** @return list */ + private function multipleResponseSets(string $datasetId): array + { + $sets = []; + foreach ($this->all('SELECT * FROM multiple_response_set WHERE dataset_id = ? ORDER BY source_ordinal', [$datasetId]) as $set) { + $members = $this->all('SELECT variable.source_name FROM multiple_response_member member LEFT JOIN variable ON variable.variable_id = member.variable_id AND variable.dataset_id = ? WHERE member.multiple_response_set_id = ? ORDER BY member.source_ordinal', [$datasetId, $set['multiple_response_set_id']]); + $counted = $set['counted_value_kind'] === null ? null : $this->value($set['counted_value_kind'], $set['counted_numeric_value'], $set['counted_string_value']); + if (is_float($counted)) { + if (floor($counted) !== $counted || $counted >= (float) PHP_INT_MAX || $counted < PHP_INT_MIN) { + throw $this->invalid('A multiple-response counted value must be an integer.'); + } + $counted = (int) $counted; + } + try { + $sets[] = new MultipleResponseSet( + $this->string($set['set_name']), + match ($set['set_kind']) { + 'MD' => MultipleResponseSetType::DICHOTOMY, + 'MC' => MultipleResponseSetType::CATEGORY, + default => throw $this->invalid('Invalid multiple-response set kind.'), + }, + array_map(fn(array $member): string => $this->string($member['source_name']), $members), + $set['set_label'] === null ? null : $this->text($set['set_label']), + $counted, + MultipleResponseCategoryLabels::from($this->string($set['category_label_behavior'])), + MultipleResponseLabelSource::from($this->string($set['label_source'])), + ); + } catch (\InvalidArgumentException|\ValueError $exception) { + throw $this->invalid('Invalid multiple-response set: ' . $exception->getMessage()); + } + } + return $sets; + } + + /** @return array> */ + private function attributes(string $sql, string $id): array + { + $attributes = []; + foreach ($this->all($sql, [$id]) as $row) { + $attributes[$this->string($row['attribute_name'])][] = $this->text($row['attribute_value']); + } + return $attributes; + } + + private function value(mixed $kind, mixed $numeric, mixed $string): float|string + { + return match ($kind) { + 'numeric' => $this->numeric($numeric), + 'string' => $this->text($string), + default => throw $this->invalid('Invalid dictionary value kind.'), + }; + } + + private function numeric(mixed $value): float + { + if ((!is_int($value) && !is_float($value) && !(is_string($value) && is_numeric($value))) || !is_finite((float) $value)) { + throw $this->invalid('Invalid numeric value.'); + } + return (float) $value; + } + + private function integer(mixed $value): int + { + if (!is_int($value) && !(is_string($value) && preg_match('/^-?[0-9]+$/D', $value) === 1)) { + throw $this->invalid('Invalid integer metadata.'); + } + return (int) $value; + } + + private function text(mixed $value): string + { + if (!is_string($value)) { + throw $this->invalid('Invalid string value.'); + } + return $value; + } + + private function string(mixed $value): string + { + $value = $this->text($value); + if ($value === '') { + throw $this->invalid('Required canonical name or identifier is empty.'); + } + return $value; + } + + private function optionalText(mixed $value): ?string + { + return $value === null || $value === '' ? null : $this->text($value); + } + + /** + * @param list $parameters + * @return list> + */ + private function all(string $sql, array $parameters): array + { + $statement = $this->pdo->prepare($sql); + if ($statement === false || !$statement->execute($parameters)) { + throw $this->invalid('Could not read the canonical dataset.'); + } + return array_values($statement->fetchAll(PDO::FETCH_ASSOC)); + } + + private function quote(string $identifier): string + { + $quote = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' ? '`' : '"'; + return $quote . str_replace($quote, $quote . $quote, $identifier) . $quote; + } + + private function invalid(string $message): UnsupportedOperation + { + return new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, $message); + } +} diff --git a/src/Sql/CatalogOwnership.php b/src/Sql/CatalogOwnership.php index 8960adf..0ec7ad3 100644 --- a/src/Sql/CatalogOwnership.php +++ b/src/Sql/CatalogOwnership.php @@ -92,6 +92,17 @@ public static function ensure(PDO $pdo): void } public static function assertReadyForUse(PDO $pdo): void + { + self::assertReady($pdo, true); + } + + /** Reads and transformation preflight must never initialize a namespace. */ + public static function assertReadyForUseReadOnly(PDO $pdo): void + { + self::assertReady($pdo, false); + } + + private static function assertReady(PDO $pdo, bool $initialize): void { if (self::tableExists($pdo, self::IDENTITY_TABLE)) { if (self::validateIdentity($pdo) < self::SCHEMA_VERSION || !self::currentMigrationComplete($pdo)) { @@ -118,22 +129,12 @@ public static function assertReadyForUse(PDO $pdo): void throw self::collision('The active database namespace contains unowned catalogue objects: ' . implode(', ', $collisions) . '.'); } - self::ensure($pdo); - throw self::migrationRequired(); - } - - /** Transformation preflight must never initialize a fresh namespace. */ - public static function assertReadyForUseReadOnly(PDO $pdo): void - { - if (!self::tableExists($pdo, self::IDENTITY_TABLE) - && !self::tableExists($pdo, self::MIGRATION_TABLE) - && self::catalogCollisions($pdo) === [] - && self::namespaceObjects($pdo) === [] - ) { - throw self::migrationRequired(); + if ($initialize) { + self::ensure($pdo); + } elseif (($objects = self::namespaceObjects($pdo)) !== []) { + throw self::collision('A fresh OpenStatSpec claim requires an empty namespace; found: ' . implode(', ', self::objectLabels($objects)) . '.'); } - - self::assertReadyForUse($pdo); + throw self::migrationRequired(); } public static function isFreshPending(PDO $pdo): bool diff --git a/tests/Integration/DoltReadOnlyExportTest.php b/tests/Integration/DoltReadOnlyExportTest.php new file mode 100644 index 0000000..316fec0 --- /dev/null +++ b/tests/Integration/DoltReadOnlyExportTest.php @@ -0,0 +1,155 @@ + PDO::ERRMODE_EXCEPTION]); + $name = 'oss_php_ro_' . bin2hex(random_bytes(6)); + $password = bin2hex(random_bytes(16)); + $target = sys_get_temp_dir() . '/' . $name; + $createdDatabase = false; + $createdUser = false; + try { + $admin->exec('CREATE DATABASE `' . $name . '`'); + $createdDatabase = true; + $admin->exec('USE `' . $name . '`'); + // Fixture setup uses low-level catalog writers, not a new public write-version claim. + CatalogOwnership::ensure($admin); + (new MySqlSchema($admin, new DoltProfile()))->createCatalog(); + (new NormativeCatalog($admin))->createTables(); + (new TransformationAuditMigrator($admin))->migrate(); + CatalogOwnership::markCurrentVersion($admin); + $fixture = new Dataset( + new VariableDictionary([new VariableMetadata('score', VariableType::NUMERIC, 0, new VariableFormat(5, 8, 0), new VariableFormat(5, 8, 0))]), + [[1.0], [null]], + new FileMetadata(label: 'original'), + new FileTechnicalMetadata(sourceFormat: 'sav', encoding: 'UTF-8'), + ); + (new MySqlWideTableImporter($admin, new DoltProfile()))->import(SpssSourceNormalizer::normalize($fixture), 'fixture', 'fixture.sav'); + $admin->exec("UPDATE dataset SET dataset_label = 'authoritative'"); + $admin->exec("UPDATE variable SET variable_label = 'canonical score'"); + foreach (["CALL DOLT_ADD('-A')", "CALL DOLT_COMMIT('-m', 'Read-only export fixture', '--author', 'OpenStatSpec test ')"] as $sql) { + $statement = $admin->query($sql); + self::assertInstanceOf(\PDOStatement::class, $statement); + $statement->closeCursor(); + } + $admin->exec("CREATE USER '" . $name . "'@'%' IDENTIFIED BY '" . $password . "'"); + $createdUser = true; + $admin->exec("GRANT SELECT ON `" . $name . "`.* TO '" . $name . "'@'%'"); + $reader = new PDO($dsn, $name, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $reader->exec('USE `' . $name . '`'); + try { + $reader->exec('UPDATE dataset SET dataset_label = dataset_label WHERE 1 = 0'); + self::fail('The reader unexpectedly has UPDATE privilege.'); + } catch (\PDOException $exception) { + self::assertStringContainsString('denied', strtolower($exception->getMessage())); + } + $adapter = new SpssAdapter($reader); + $capabilities = $adapter->capabilities(); + self::assertSame('dolt', $capabilities['active_connection']['profile']); + $before = $this->state($admin); + foreach (['sav', 'zsav'] as $format) { + $result = $adapter->export('fixture', $target . '.' . $format); + self::assertSame(2, $result->caseCount); + self::assertArrayNotHasKey('operationId', get_object_vars($result)); + $output = (new PhpSpssEngine())->read($target . '.' . $format); + self::assertSame($fixture->rows(), $output->rows()); + self::assertSame('authoritative', $output->metadata->label); + self::assertSame('canonical score', $output->variables()[0]->label); + self::assertSame($format, $output->technicalMetadata->sourceFormat); + } + foreach ([['absent', 'sav', DiagnosticCode::InvalidSourceDataset], ['fixture', 'por', DiagnosticCode::UnsupportedSourceFormat]] as [$dataset, $format, $code]) { + try { + $adapter->export($dataset, $target . '.' . $format); + self::fail('Invalid export succeeded.'); + } catch (UnsupportedOperation $exception) { + self::assertSame($code, $exception->diagnosticCode); + } + } + $engine = new FakeSpssEngine($fixture); + $engine->writeFailure = new \RuntimeException('writer failed'); + file_put_contents($target . '.sav', 'keep destination'); + try { + (new SpssAdapter($reader, $engine))->export('fixture', $target . '.sav'); + self::fail('Writer failure was swallowed.'); + } catch (\RuntimeException $exception) { + self::assertSame('writer failed', $exception->getMessage()); + } + self::assertSame('keep destination', file_get_contents($target . '.sav')); + self::assertFileDoesNotExist($engine->lastWrite()['targetPath']); + if (!$capabilities['active_connection']['claimed_supported']) { + foreach (['import', 'migrateCatalog'] as $method) { + try { + if ($method === 'import') { + $adapter->import('unused.sav', 'forbidden'); + } else { + $adapter->migrateCatalog(); + } + self::fail('An unclaimed server version permitted a public write.'); + } catch (UnsupportedOperation $exception) { + self::assertSame(DiagnosticCode::TargetCapabilityExceeded, $exception->diagnosticCode); + } + } + } + self::assertSame($before, $this->state($admin)); + } finally { + @unlink($target . '.sav'); + @unlink($target . '.zsav'); + if ($createdUser) { + $admin->exec("DROP USER '" . $name . "'@'%'"); + } + if ($createdDatabase) { + $admin->exec('DROP DATABASE `' . $name . '`'); + } + } + } + + /** @return array>> */ + private function state(PDO $pdo): array + { + $state = []; + foreach ([ + 'root' => "SELECT DOLT_HASHOF_DB('WORKING') AS working_root, DOLT_HASHOF_DB('STAGED') AS staged_root", + 'status' => 'SELECT * FROM dolt_status ORDER BY table_name', + 'history' => 'SELECT * FROM dolt_log ORDER BY commit_hash', + 'tables' => 'SHOW TABLES', + ] as $name => $sql) { + $statement = $pdo->query($sql); + self::assertInstanceOf(\PDOStatement::class, $statement); + $state[$name] = array_values($statement->fetchAll(PDO::FETCH_ASSOC)); + } + return $state; + } +} diff --git a/tests/Spss/SpssAdapterTest.php b/tests/Spss/SpssAdapterTest.php index fed5365..f94390e 100644 --- a/tests/Spss/SpssAdapterTest.php +++ b/tests/Spss/SpssAdapterTest.php @@ -124,12 +124,90 @@ public function testExportRequiresExplicitMigrationForOlderIdentityWithoutJourna self::assertSame([['schema_version' => 1]], self::rows($pdo, 'SELECT schema_version FROM catalog_identity')); $adapter->migrateCatalog(); - $result = $adapter->export('Old catalog export', 'old-catalog.sav'); + $target = sys_get_temp_dir() . '/oss-' . bin2hex(random_bytes(8)) . '.sav'; + $result = $adapter->export('Old catalog export', $target); + unlink($target); self::assertSame(2, $result->caseCount); self::assertSame([['schema_version' => 4]], self::rows($pdo, 'SELECT schema_version FROM catalog_identity')); } + public function testExportIsReadOnlyAndPreservesDestinationOnWriterFailure(): void + { + $pdo = new PDO('sqlite::memory:'); + $engine = new FakeSpssEngine($this->fixture()); + $adapter = new SpssAdapter($pdo, $engine); + $adapter->import('fixture.sav', 'read only'); + $pdo->exec("UPDATE variable SET variable_label = 'Authoritative label' WHERE source_ordinal = 1"); + $before = self::rows($pdo, 'SELECT total_changes() AS changes'); + $pdo->exec('PRAGMA query_only = ON'); + $target = sys_get_temp_dir() . '/oss-export-' . bin2hex(random_bytes(8)) . '.zsav'; + try { + $result = $adapter->export('read only', $target, allowLoss: ['caller_accepted_loss']); + self::assertSame(['caller_accepted_loss'], $result->allowLoss); + self::assertSame($target, $result->targetPath); + self::assertArrayNotHasKey('operationId', get_object_vars($result)); + self::assertSame('Authoritative label', $engine->lastWrite()['dataset']->variables()[0]->label); + self::assertSame('zsav', $engine->lastWrite()['dataset']->technicalMetadata->sourceFormat); + file_put_contents($target, 'existing destination'); + $engine->writeFailure = new \RuntimeException('writer failed'); + try { + $adapter->export('read only', $target); + self::fail('Writer failure was swallowed.'); + } catch (\RuntimeException $exception) { + self::assertSame('writer failed', $exception->getMessage()); + } + self::assertSame('existing destination', file_get_contents($target)); + self::assertFileDoesNotExist($engine->lastWrite()['targetPath']); + self::assertSame($before, self::rows($pdo, 'SELECT total_changes() AS changes')); + } finally { + @unlink($target); + } + } + + public function testExportRejectsInvalidNormativeMetadataWithoutTouchingDestinationOrDatabase(): void + { + $pdo = new PDO('sqlite::memory:'); + $adapter = new SpssAdapter($pdo, new FakeSpssEngine($this->fixture())); + $adapter->import('fixture.sav', 'invalid dictionary'); + $pdo->exec("UPDATE variable SET storage_kind = 'invalid' WHERE source_ordinal = 1"); + $before = self::rows($pdo, 'SELECT total_changes() AS changes'); + $pdo->exec('PRAGMA query_only = ON'); + $target = sys_get_temp_dir() . '/oss-' . bin2hex(random_bytes(8)) . '.sav'; + file_put_contents($target, 'keep destination'); + try { + $adapter->export('invalid dictionary', $target, allowLoss: ['invalid_source_dataset']); + self::fail('allowLoss bypassed invalid dictionary validation.'); + } catch (UnsupportedOperation $exception) { + self::assertSame(DiagnosticCode::InvalidSourceDataset, $exception->diagnosticCode); + self::assertSame('keep destination', file_get_contents($target)); + self::assertSame($before, self::rows($pdo, 'SELECT total_changes() AS changes')); + } finally { + unlink($target); + } + } + + public function testExportDoesNotInitializeFreshOrPendingCatalogs(): void + { + foreach ([false, true] as $pending) { + $pdo = new PDO('sqlite::memory:'); + if ($pending) { + CatalogOwnership::ensure($pdo); + } + $before = self::rows($pdo, 'SELECT * FROM sqlite_master'); + $changes = self::rows($pdo, 'SELECT total_changes() AS changes'); + $pdo->exec('PRAGMA query_only = ON'); + try { + (new SpssAdapter($pdo))->export('absent', 'unused.sav'); + self::fail('Export initialized a catalog.'); + } catch (UnsupportedOperation $exception) { + self::assertSame(DiagnosticCode::CatalogMigrationRequired, $exception->diagnosticCode); + } + self::assertSame($before, self::rows($pdo, 'SELECT * FROM sqlite_master')); + self::assertSame($changes, self::rows($pdo, 'SELECT total_changes() AS changes')); + } + } + public function testReadOnlyFreshInitializationFailureDoesNotCreateCurrentIdentity(): void { $pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); @@ -235,20 +313,32 @@ public function testTypedDatasetImportCreatesWideTableAndExportPreservesSupporte ], self::rows($pdo, 'SELECT set_table.name, set_table.set_type, set_table.counted_value_kind, set_table.counted_text_value, set_table.category_labels, set_table.label_source, member.member_ordinal, member.variable_ordinal FROM multiple_response_sets set_table JOIN multiple_response_set_members member ON member.dataset_name = set_table.dataset_name AND member.set_ordinal = set_table.set_ordinal WHERE set_table.dataset_name = "Customer survey" ORDER BY set_table.set_ordinal, member.member_ordinal'), ); - $result = $adapter->export('Customer survey', 'roundtrip.sav'); + // A complete dictionary must come from normative rows, not the legacy read model. + foreach ([ + 'multiple_response_set_members', 'multiple_response_sets', + 'variable_set_members', 'variable_sets', 'variable_attributes', + 'file_attributes', 'variable_roles', 'variable_display_metadata', + 'missing_rule_values', 'missing_rules', 'value_labels', 'documents', + 'dataset_metadata', 'dataset_weight_variables', 'variables', + ] as $table) { + $pdo->exec('DELETE FROM ' . $table); + } + $pdo->exec('PRAGMA query_only = ON'); + $target = sys_get_temp_dir() . '/oss-' . bin2hex(random_bytes(8)) . '.sav'; + $result = $adapter->export('Customer survey', $target); + unlink($target); $written = $engine->lastWrite()['dataset']; self::assertSame([], $result->diagnostics); self::assertSame([], $result->allowLoss); - self::assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $result->operationId); + self::assertArrayNotHasKey('operationId', get_object_vars($result)); self::assertSame( [ ['direction' => 'import', 'status' => 'succeeded', 'dataset_name' => 'Customer survey', 'target_path' => 'fixture.sav', 'allow_loss' => '[]', 'failure_code' => null], - ['direction' => 'export', 'status' => 'succeeded', 'dataset_name' => 'Customer survey', 'target_path' => 'roundtrip.sav', 'allow_loss' => '[]', 'failure_code' => null], ], self::rows($pdo, 'SELECT direction, status, dataset_name, target_path, allow_loss, failure_code FROM operation_catalog ORDER BY rowid'), ); - self::assertSame(["{\"package\":\"fake-spss-engine\",\"version\":\"test\",\"active_version\":\"test\",\"claimed_version_range\":\"test-only\",\"ci_tested_versions\":[\"test\"],\"claimed_supported\":true}", "{\"package\":\"fake-spss-engine\",\"version\":\"test\",\"active_version\":\"test\",\"claimed_version_range\":\"test-only\",\"ci_tested_versions\":[\"test\"],\"claimed_supported\":true}"], array_column(self::rows($pdo, 'SELECT engine_details FROM operation_catalog ORDER BY rowid'), 'engine_details')); + self::assertSame(["{\"package\":\"fake-spss-engine\",\"version\":\"test\",\"active_version\":\"test\",\"claimed_version_range\":\"test-only\",\"ci_tested_versions\":[\"test\"],\"claimed_supported\":true}"], array_column(self::rows($pdo, 'SELECT engine_details FROM operation_catalog ORDER BY rowid'), 'engine_details')); self::assertSame('Customer survey source', $written->metadata->label); self::assertSame('Respondent ID', $written->metadata->weightVariableName); self::assertSame(['First document line', 'Second document line'], $written->metadata->documents()); @@ -856,12 +946,14 @@ public function testExportReadsCanonicalCatalogInsteadOfLegacyMetadata(): void $adapter->import('fixture.sav', 'canonical fixture'); $pdo->exec("UPDATE variable SET variable_label = 'Canonical label' WHERE dataset_id = (SELECT dataset_id FROM dataset WHERE dataset_name = 'canonical fixture') AND source_ordinal = 1"); $pdo->exec("UPDATE variables SET label = 'Legacy-only label' WHERE dataset_name = 'canonical fixture' AND ordinal = 1"); - $adapter->export('canonical fixture', 'canonical-roundtrip.sav'); + $target = sys_get_temp_dir() . '/oss-' . bin2hex(random_bytes(8)) . '.sav'; + $adapter->export('canonical fixture', $target); + unlink($target); self::assertSame('Canonical label', $engine->lastWrite()['dataset']->variables()[0]->label); $legacyLabel = $pdo->query("SELECT label FROM variables WHERE dataset_name = 'canonical fixture' AND ordinal = 1"); self::assertInstanceOf(\PDOStatement::class, $legacyLabel); - self::assertSame('Canonical label', $legacyLabel->fetchColumn()); + self::assertSame('Legacy-only label', $legacyLabel->fetchColumn()); } public function testExportRestoresCataloguedTechnicalMetadataWhileTargetDeterminesContainer(): void @@ -873,7 +965,9 @@ public function testExportRestoresCataloguedTechnicalMetadataWhileTargetDetermin $engine = new FakeSpssEngine($this->fixture()); $adapter = new SpssAdapter(new PDO('sqlite::memory:'), $engine); $adapter->import('fixture.zsav', 'technical fixture'); - $adapter->export('technical fixture', 'technical-roundtrip.sav'); + $target = sys_get_temp_dir() . '/oss-' . bin2hex(random_bytes(8)) . '.sav'; + $adapter->export('technical fixture', $target); + unlink($target); $technical = $engine->lastWrite()['dataset']->technicalMetadata; self::assertSame('sav', $technical->sourceFormat); @@ -948,7 +1042,9 @@ public function testMigrateCatalogBackfillsLegacyDatasetsAndIsIdempotent(): void self::rows($pdo, 'SELECT COUNT(*) AS variable_count FROM variable'), ); - $export = $adapter->export('Legacy survey', 'legacy-roundtrip.sav'); + $target = sys_get_temp_dir() . '/oss-' . bin2hex(random_bytes(8)) . '.sav'; + $export = $adapter->export('Legacy survey', $target); + unlink($target); self::assertSame(2, $export->caseCount); self::assertSame([[7.0, 'blue'], [8.0, 'green']], $engine->lastWrite()['dataset']->rows()); } diff --git a/tests/Support/FakeSpssEngine.php b/tests/Support/FakeSpssEngine.php index d029fe3..e5c95a4 100644 --- a/tests/Support/FakeSpssEngine.php +++ b/tests/Support/FakeSpssEngine.php @@ -13,6 +13,7 @@ final class FakeSpssEngine implements SpssEngine /** @var list */ private array $writes = []; private ?string $lastReadPath = null; + public ?Throwable $writeFailure = null; /** @param ?array $identityOverride */ public function __construct( @@ -83,6 +84,10 @@ public function lastReadPath(): string public function write(string $targetPath, Dataset $dataset): void { $this->writes[] = ['targetPath' => $targetPath, 'dataset' => $dataset]; + if ($this->writeFailure !== null) { + file_put_contents($targetPath, 'partial output'); + throw $this->writeFailure; + } } /** @return array{targetPath: string, dataset: Dataset} */ From 1bcc37d8557a30d46d2b0b9b945af4844ef9ac4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 7 Sep 2026 17:49:28 +0300 Subject: [PATCH 2/4] Export transformed canonical metadata with numeric format defaults --- src/Sql/CanonicalWideTableExporter.php | 16 +++++- tests/Spss/SpssAdapterTest.php | 77 +++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/Sql/CanonicalWideTableExporter.php b/src/Sql/CanonicalWideTableExporter.php index 29a44a9..11a5168 100644 --- a/src/Sql/CanonicalWideTableExporter.php +++ b/src/Sql/CanonicalWideTableExporter.php @@ -78,7 +78,12 @@ public function export(string $datasetName, string $targetFormat = 'sav'): array label: $variable['variable_label'] === null ? null : $this->text($variable['variable_label']), valueLabels: new ValueLabelSet($labels, [$name]), missingValues: $this->missingValues($variableId), - measure: Measure::tryFrom($this->integer($variable['measurement_level'] ?? 0)) ?? Measure::UNKNOWN, + measure: match ($variable['measurement_level']) { + 'nominal' => Measure::NOMINAL, + 'ordinal' => Measure::ORDINAL, + 'scale' => Measure::SCALE, + default => Measure::tryFrom($this->integer($variable['measurement_level'] ?? 0)) ?? Measure::UNKNOWN, + }, alignment: Alignment::tryFrom($this->integer($variable['display_alignment'] ?? 0)) ?? Alignment::LEFT, columns: max(0, $this->integer($variable['display_width'] ?? 8)), role: VariableRole::tryFrom($this->integer($variable['variable_role'] ?? 0)) ?? throw $this->invalid('Invalid variable role.'), @@ -147,9 +152,16 @@ public function export(string $datasetName, string $targetFormat = 'sav'): array /** @param array $variable */ private function format(array $variable, string $prefix, VariableType $type): VariableFormat { + if ($type === VariableType::NUMERIC + && ($variable[$prefix . '_format_family'] ?? null) === null + && ($variable[$prefix . '_format_width'] ?? null) === null + && ($variable[$prefix . '_format_decimals'] ?? null) === null + ) { + return new VariableFormat(5, 8, 2); + } $width = $this->integer($variable[$prefix . '_format_width']); return new VariableFormat( - $this->integer($variable[$prefix . '_format_family']), + $variable[$prefix . '_format_family'] === 'F' ? 5 : $this->integer($variable[$prefix . '_format_family']), $type === VariableType::STRING ? min($width, 255) : $width, $this->integer($variable[$prefix . '_format_decimals']), ); diff --git a/tests/Spss/SpssAdapterTest.php b/tests/Spss/SpssAdapterTest.php index f94390e..89a63ff 100644 --- a/tests/Spss/SpssAdapterTest.php +++ b/tests/Spss/SpssAdapterTest.php @@ -11,6 +11,11 @@ use OpenStatSpec\Spss\SpssAdapter; use OpenStatSpec\Spss\SpssSourceNormalizer; use OpenStatSpec\Sql\CatalogOwnership; +use OpenStatSpec\Sql\Connection; +use OpenStatSpec\Transformation\Execution\InPlaceApplyRequest; +use OpenStatSpec\Transformation\Execution\InPlaceTransformationExecutor; +use OpenStatSpec\Transformation\Plan\PlanCodec; +use PHPUnit\Framework\Attributes\DataProvider; use OpenStatSpec\Sql\NormativeCatalog; use OpenStatSpec\Sql\SqliteWideTableImporter; use OpenStatSpec\Tests\Support\FakeSpssEngine; @@ -165,12 +170,23 @@ public function testExportIsReadOnlyAndPreservesDestinationOnWriterFailure(): vo } } - public function testExportRejectsInvalidNormativeMetadataWithoutTouchingDestinationOrDatabase(): void + /** @return iterable */ + public static function invalidExportMetadata(): iterable + { + yield 'storage' => ["storage_kind = 'invalid'"]; + yield 'unknown format' => ["print_format_family = 'invalid'"]; + yield 'empty format' => ["print_format_family = ''"]; + yield 'partial format' => ['print_format_width = NULL']; + yield 'unknown measurement' => ["measurement_level = 'invalid'"]; + } + + #[DataProvider('invalidExportMetadata')] + public function testExportRejectsInvalidNormativeMetadataWithoutTouchingDestinationOrDatabase(string $assignment): void { $pdo = new PDO('sqlite::memory:'); $adapter = new SpssAdapter($pdo, new FakeSpssEngine($this->fixture())); $adapter->import('fixture.sav', 'invalid dictionary'); - $pdo->exec("UPDATE variable SET storage_kind = 'invalid' WHERE source_ordinal = 1"); + $pdo->exec("UPDATE variable SET $assignment WHERE source_ordinal = 1"); $before = self::rows($pdo, 'SELECT total_changes() AS changes'); $pdo->exec('PRAGMA query_only = ON'); $target = sys_get_temp_dir() . '/oss-' . bin2hex(random_bytes(8)) . '.sav'; @@ -735,6 +751,63 @@ public function testImportRejectsInvalidVerifiedSha256BeforeDatabaseMutation(): } } + /** @return iterable>, ?Measure}> */ + public static function exportTransformations(): iterable + { + yield 'format' => [[['op' => 'set_format', 'variable' => 'Respondent_ID', 'family' => 'F', 'width' => 10, 'decimals' => 2]], null]; + foreach (['nominal' => Measure::NOMINAL, 'ordinal' => Measure::ORDINAL, 'scale' => Measure::SCALE] as $level => $measure) { + yield $level => [[['op' => 'set_measurement_level', 'variable' => 'Respondent_ID', 'level' => $level]], $measure]; + } + yield 'assignment target' => [[['op' => 'assign', 'target' => 'Created', 'target_mode' => 'create', 'value' => ['kind' => 'variable', 'variable' => 'Respondent_ID']]], null]; + yield 'recode target' => [[['op' => 'recode', 'source' => 'Respondent_ID', 'target' => 'Created', 'target_mode' => 'create', 'rules' => [['match' => ['kind' => 'values', 'values' => [['type' => 'binary64', 'bits' => '401c000000000000']]], 'result' => ['kind' => 'copy']]], 'unmatched' => ['kind' => 'copy']]], null]; + } + + /** @param list> $operations */ + #[DataProvider('exportTransformations')] + public function testRealExportAfterTransformation(array $operations, ?Measure $measure): void + { + $source = sys_get_temp_dir() . '/oss-transform-' . bin2hex(random_bytes(8)) . '.sav'; + $target = $source . '.zsav'; + $engine = new PhpSpssEngine(); + $pdo = new PDO('sqlite::memory:'); + $adapter = new SpssAdapter($pdo, $engine); + try { + $engine->write($source, $this->engineFixture()); + $adapter->import($source, 'transformed'); + // Imported IDs currently have random UUID variant bits; use a valid apply binding. + $pdo->exec('PRAGMA foreign_keys = OFF'); + foreach (['dataset', 'variable', 'value_label_set', 'dataset_weight_variable', 'document', 'fidelity_event'] as $table) { + $pdo->exec("UPDATE $table SET dataset_id = '018f47f2-8b6a-4c3d-8e1f-123456789abc' WHERE dataset_id IS NOT NULL"); + } + $pdo->exec('PRAGMA foreign_keys = ON'); + $dataset = self::rows($pdo, 'SELECT dataset_id, source_hash FROM dataset')[0]; + $plan = (new PlanCodec())->fromArray(['contract' => 'openstatspec-transformation-plan-v0.2', 'input_alias' => 'parent', 'operations' => $operations]); + (new InPlaceTransformationExecutor(new Connection($pdo)))->execute(new InPlaceApplyRequest($plan, 'parent', $dataset['dataset_id'], $dataset['source_hash'], 'export regression')); + $before = self::rows($pdo, 'SELECT total_changes() AS changes'); + $pdo->exec('PRAGMA query_only = ON'); + foreach (['sav', 'zsav'] as $format) { + $target = $source . '.' . $format; + $adapter->export('transformed', $target); + $read = $engine->read($target); + $created = isset($operations[0]['target']); + self::assertSame($created ? [[7.0, 'blue', 7.0], [8.0, 'green', 8.0]] : $this->engineFixture()->rows(), $read->rows()); + self::assertSame($measure ?? Measure::SCALE, $read->variables()[0]->measure); + $expected = new VariableFormat(5, $operations[0]['op'] === 'set_format' ? 10 : 8, $operations[0]['op'] === 'set_format' ? 2 : 0); + self::assertEquals($expected, $read->variables()[0]->printFormat); + self::assertEquals($expected, $read->variables()[0]->writeFormat); + if ($created) { + self::assertEquals(new VariableFormat(5, 8, 2), $read->variables()[2]->printFormat); + self::assertEquals(new VariableFormat(5, 8, 2), $read->variables()[2]->writeFormat); + } + self::assertSame($before, self::rows($pdo, 'SELECT total_changes() AS changes')); + unlink($target); + } + } finally { + @unlink($source); + @unlink($target); + } + } + public function testPhpSpssEngineWritesAndReadsTypedDataset(): void { $target = sys_get_temp_dir() . '/openstatspec-v3-' . uniqid('', true) . '.sav'; From 8097cd992d6460a5a892d579db42a0be1ccfb4ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 7 Sep 2026 17:50:27 +0300 Subject: [PATCH 3/4] Select database I/O policy and restrict Dolt writes to tested versions --- README.md | 15 +++++++++------ docs/architecture.md | 2 +- src/Core/CapabilityDeclaration.php | 7 +++---- src/Core/ServerVersionPolicy.php | 4 ++-- tests/Core/CapabilityDeclarationTest.php | 6 ++++-- tests/Core/ServerVersionPolicyTest.php | 15 ++++++++------- tests/Sql/ServerIdentityTest.php | 2 +- 7 files changed, 28 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 77ad70c..b18946a 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,11 @@ It imports an unencrypted SPSS `.sav` or `.zsav` dataset into a relational datab This is an early reference implementation. Its round-trip contract is **semantic**, not byte-identical: supported cases, order, variables, values, dictionary metadata and technical metadata are preserved; compression layout, timestamps and other writer-specific bytes are not promised. SQLite, PostgreSQL 17.x/18.x, MySQL 8.4.x/9.7.x, MariaDB -11.4.x/11.8.x/12.3.x and Dolt 2.2.x with the explicit -`>=2.2.2,<2.3.0` floor/range are implemented PDO profiles. +11.4.x/11.8.x/12.3.x and exact Dolt 2.2.2/2.2.3 are implemented PDO write profiles. +The SAV/ZSAV 1.0 capability declaration selects +`database_io_policy: openstatspec-database-io-v1`: exports are database-read-only, +and default Dolt writes use the packaged exact-version policy without external +declaration files. Unknown patches (including 2.2.4) fail before mutation. Server-family claims are conservative compatibility policies; CI records exact evidence at PostgreSQL 17.10/18.4, MySQL 8.4.11/9.7.2 and MariaDB 11.4.12/11.8.8/12.3.2, and Dolt 2.2.2/2.2.3. Each service job verifies its live normalized product @@ -24,7 +27,7 @@ version before the run counts as evidence. | PostgreSQL / `postgresql` | 17.x and 18.x | 17.10 and 18.4 | | MySQL / `mysql` | 8.4.x and 9.7.x | 8.4.11 and 9.7.2 | | MariaDB / `mariadb` | 11.4.x, 11.8.x, and 12.3.x | 11.4.12, 11.8.8, and 12.3.2 | -| Dolt / `dolt` | 2.2.x family with `>=2.2.2,<2.3.0` | 2.2.2 and 2.2.3 | +| Dolt / `dolt` | Writes: exact 2.2.2 and 2.2.3 only | 2.2.2 and 2.2.3 | The PHP SQLite core profile remains `>=3.24.0,<4.0.0`. The Python adapter's optional Transformation Workflow has its own narrower `>=3.35.0,<4.0.0` @@ -254,9 +257,9 @@ SAV and ZSAV integration round trips against exact PostgreSQL 17.10/18.4, MySQL 8.4.11/9.7.2, MariaDB 11.4.12/11.8.8/12.3.2, and Dolt 2.2.2/2.2.3. Those checks use their PDO drivers and php-spss V3 read/write paths, not only -DDL snapshots. Family policies remain runtime claims and exact patches are CI -evidence points; Dolt's 2.2.x family claim additionally has an explicit 2.2.2 -minimum and 2.3.0 exclusive upper bound for writes. +DDL snapshots. Other engines retain family policies; the packaged Dolt write +list is limited to the exact 2.2.2 and 2.2.3 versions tested by existing CI. +It does not infer evidence or support for any other patch. Optional SELECT-only export coverage (also usable on Dolt 2.3.0) creates and removes its own unique test database and user. It checks SAV/ZSAV output, failure diff --git a/docs/architecture.md b/docs/architecture.md index 1e4fe4c..6d62ae7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ The public `SpssAdapter` chooses a profile from the PDO driver: | `sqlite` | SQLite | in-memory unit and round-trip suite | | `pgsql` | PostgreSQL | 17.x/18.x claim; live 17.10/18.4 SAV/ZSAV CI evidence | | `mysql` | MySQL/MariaDB | MySQL 8.4.x/9.7.x and MariaDB 11.4.x/11.8.x/12.3.x claims; live 8.4.11/9.7.2 and 11.4.12/11.8.8/12.3.2 CI evidence | -| `mysql` | Dolt | 2.2.x claim with `>=2.2.2,<2.3.0` range; live 2.2.2/2.2.3 SAV/ZSAV CI evidence; detected by `@@version_comment` plus `DOLT_VERSION()` | +| `mysql` | Dolt | Packaged writes on exact 2.2.2/2.2.3 only under `openstatspec-database-io-v1` with SAV/ZSAV 1.0; live CI evidence for those exact versions; detected by `@@version_comment` plus `DOLT_VERSION()` | Every profile creates the same logical strict-wide layout and metadata catalogue. Physical SQL types, identifier limits and capability preflight are profile-specific. A source that cannot be represented must be rejected before an incomplete substitute is created. diff --git a/src/Core/CapabilityDeclaration.php b/src/Core/CapabilityDeclaration.php index f6cc97f..98cbf91 100644 --- a/src/Core/CapabilityDeclaration.php +++ b/src/Core/CapabilityDeclaration.php @@ -44,6 +44,7 @@ public function toArray(): array 'specification_release' => self::SPECIFICATION_RELEASE, 'specification_commit' => self::SPECIFICATION_COMMIT, 'profile' => 'SPSS SAV/ZSAV 1.0', + 'database_io_policy' => 'openstatspec-database-io-v1', 'directions' => ['import', 'export', 'semantic_round_trip'], 'required_capabilities' => $this->engine->capabilities(), 'engine' => $this->engine->identity(), @@ -161,10 +162,8 @@ private function profile(string $name, PdoSqlProfile $profile, bool $active): ar 'active_probe_results' => $active ? $this->connection->identityProbeResults : null, ] : null, 'claimed_server_versions' => ServerVersionPolicy::claim($name), - 'claimed_version_range' => $name === 'dolt' ? [ - 'minimum_inclusive' => '2.2.2', - 'maximum_exclusive' => '2.3.0', - ] : null, + 'claimed_version_range' => null, + 'exact_supported_versions' => $name === 'dolt' ? ['2.2.2', '2.2.3'] : null, 'ci_tested_server_versions' => ServerVersionPolicy::ciTestedVersions($name), 'exact_ci_tested_versions' => $name === 'dolt' ? ['2.2.2', '2.2.3'] : null, 'theoretical_limits' => $theoretical, diff --git a/src/Core/ServerVersionPolicy.php b/src/Core/ServerVersionPolicy.php index 21ec039..27929bd 100644 --- a/src/Core/ServerVersionPolicy.php +++ b/src/Core/ServerVersionPolicy.php @@ -14,7 +14,7 @@ public static function assess(string $profile, string $serverVersion): array $supported = match ($profile) { 'mysql' => preg_match('/^(?:MySQL\s+)?(?:8\.4|9\.7)(?:\.|$)/i', trim($serverVersion)) === 1, 'mariadb' => preg_match('/^(?:MariaDB\s+)?(?:11\.4|11\.8|12\.3)(?:\.|$)/i', trim($serverVersion)) === 1, - 'dolt' => preg_match('/\A2\.2\.(?:[2-9]|[1-9]\d+)\z/', trim($serverVersion)) === 1, + 'dolt' => in_array(trim($serverVersion), ['2.2.2', '2.2.3'], true), 'postgresql' => preg_match('/^(?:PostgreSQL\s+)?(?:17|18)(?:\.|$)/i', trim($serverVersion)) === 1, 'sqlite' => self::supportsSqlite($serverVersion), default => false, @@ -40,7 +40,7 @@ public static function claim(string $profile): string return match ($profile) { 'mysql' => 'MySQL 8.4.x or 9.7.x', 'mariadb' => 'MariaDB 11.4.x, 11.8.x or 12.3.x', - 'dolt' => 'Dolt 2.2.x (>=2.2.2 <2.3.0)', + 'dolt' => 'Dolt 2.2.2 or 2.2.3', 'postgresql' => 'PostgreSQL 17.x or 18.x', 'sqlite' => 'SQLite >=3.24.0 <4.0.0', default => 'unsupported', diff --git a/tests/Core/CapabilityDeclarationTest.php b/tests/Core/CapabilityDeclarationTest.php index ee372ad..00a1ca4 100644 --- a/tests/Core/CapabilityDeclarationTest.php +++ b/tests/Core/CapabilityDeclarationTest.php @@ -15,6 +15,7 @@ public function testDeclarationIsMachineReadableAndIncludesEveryProfileLimit(): { $pdo = new \PDO('sqlite::memory:'); $declaration = (new CapabilityDeclaration($pdo, new PhpSpssEngine()))->toArray(); + self::assertSame('openstatspec-database-io-v1', $declaration['database_io_policy']); self::assertSame('stable', $declaration['specification_status']); self::assertSame('v0.3.0', $declaration['specification_release']); self::assertSame(CapabilityDeclaration::SPECIFICATION_RELEASE, $declaration['specification_release']); @@ -103,8 +104,9 @@ public function testDeclarationIsMachineReadableAndIncludesEveryProfileLimit(): self::assertSame(307, $profile['observed_limits']['minimum_observed_physical_columns']); self::assertSame(64, $profile['observed_limits']['identifier_limit']['value']); self::assertSame(65, $profile['observed_limits']['rejected_identifier_bytes']); - self::assertSame(['minimum_inclusive' => '2.2.2', 'maximum_exclusive' => '2.3.0'], $profile['claimed_version_range']); - self::assertSame('Dolt 2.2.x (>=2.2.2 <2.3.0)', $profile['claimed_server_versions']); + self::assertNull($profile['claimed_version_range']); + self::assertSame(['2.2.2', '2.2.3'], $profile['exact_supported_versions']); + self::assertSame('Dolt 2.2.2 or 2.2.3', $profile['claimed_server_versions']); self::assertSame('observed_exact_version', $profile['limit_bases']['identifier_limit']); self::assertTrue($profile['storage_evidence']['binary64']['maximum_finite_round_trip_exact']); self::assertSame('reject_before_mutation', $profile['storage_evidence']['binary64']['non_finite_policy']); diff --git a/tests/Core/ServerVersionPolicyTest.php b/tests/Core/ServerVersionPolicyTest.php index a962d53..4fd96a8 100644 --- a/tests/Core/ServerVersionPolicyTest.php +++ b/tests/Core/ServerVersionPolicyTest.php @@ -31,9 +31,10 @@ public static function versions(): iterable yield 'MariaDB old' => ['mariadb', '10.11.11-MariaDB', false, null]; yield 'MariaDB unclaimed family' => ['mariadb', '11.5.0-MariaDB', false, null]; yield 'MariaDB future family' => ['mariadb', '12.4.0-MariaDB', false, null]; - yield 'Dolt floor supported' => ['dolt', '2.2.2', true, 'Dolt 2.2.x (>=2.2.2 <2.3.0)']; - yield 'Dolt exact latest CI release supported' => ['dolt', '2.2.3', true, 'Dolt 2.2.x (>=2.2.2 <2.3.0)']; - yield 'Dolt future patch supported' => ['dolt', '2.2.999', true, 'Dolt 2.2.x (>=2.2.2 <2.3.0)']; + yield 'Dolt floor supported' => ['dolt', '2.2.2', true, 'Dolt 2.2.2 or 2.2.3']; + yield 'Dolt exact latest CI release supported' => ['dolt', '2.2.3', true, 'Dolt 2.2.2 or 2.2.3']; + yield 'Dolt unknown patch rejected' => ['dolt', '2.2.4', false, null]; + yield 'Dolt future patch rejected' => ['dolt', '2.2.999', false, null]; yield 'Dolt below floor 2.2.0' => ['dolt', '2.2.0', false, null]; yield 'Dolt below floor 2.2.1' => ['dolt', '2.2.1', false, null]; yield 'Dolt previous family' => ['dolt', '2.1.999', false, null]; @@ -93,7 +94,7 @@ public function testNormalizesStableProductVersionWidth(string $profile, string self::assertSame($expected, ServerVersionPolicy::normalize($profile, $version)); } - public function testDoltBelowFloorFailsBeforeDdl(): void + public function testDoltUnknownPatchFailsBeforeDdl(): void { $pdo = $this->createMock(PDO::class); $pdo->method('getAttribute')->willReturnCallback(static fn(int $attribute): string => match ($attribute) { @@ -105,7 +106,7 @@ public function testDoltBelowFloorFailsBeforeDdl(): void $value = match ($query) { 'SELECT @@version' => '8.0.33', 'SELECT @@version_comment' => 'Dolt', - 'SELECT DOLT_VERSION()' => '2.2.1', + 'SELECT DOLT_VERSION()' => '2.2.4', default => throw new \LogicException('Unexpected identity probe: ' . $query), }; $statement = $this->createMock(PDOStatement::class); @@ -119,10 +120,10 @@ public function testDoltBelowFloorFailsBeforeDdl(): void $adapter = new SpssAdapter($pdo); try { $adapter->migrateCatalog(); - self::fail('A Dolt release below the claimed floor was accepted.'); + self::fail('An untested Dolt patch was accepted.'); } catch (UnsupportedOperation $exception) { self::assertSame(DiagnosticCode::TargetCapabilityExceeded, $exception->diagnosticCode); - self::assertStringContainsString('outside the claimed profile Dolt 2.2.x (>=2.2.2 <2.3.0)', $exception->getMessage()); + self::assertStringContainsString('outside the claimed profile Dolt 2.2.2 or 2.2.3', $exception->getMessage()); } } diff --git a/tests/Sql/ServerIdentityTest.php b/tests/Sql/ServerIdentityTest.php index 2415c47..afcac49 100644 --- a/tests/Sql/ServerIdentityTest.php +++ b/tests/Sql/ServerIdentityTest.php @@ -38,7 +38,7 @@ public function testDoltRequiresEverySignalAndUsesProductVersion(): void $connection = new Connection($pdo); self::assertTrue($connection->claimedSupported); - self::assertSame('Dolt 2.2.x (>=2.2.2 <2.3.0)', $connection->matchedClaim); + self::assertSame('Dolt 2.2.2 or 2.2.3', $connection->matchedClaim); self::assertInstanceOf(DoltProfile::class, $connection->profile); } From bf17d15a7ac096ccfd8149c647a04169f0cd0a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 7 Sep 2026 18:30:22 +0300 Subject: [PATCH 4/4] Prepare v0.7.0 release pins and read-only Dolt CI --- .github/workflows/ci.yml | 15 +++--- CHANGELOG.md | 34 ++++++++++++ README.md | 21 ++++++-- docs/release-readiness.md | 60 ++++++++++++++++------ src/Core/CapabilityDeclaration.php | 6 +-- tests/Core/CapabilityDeclarationTest.php | 8 +-- tests/Release/CiTransformationGateTest.php | 13 +++++ tests/Release/SpecificationPinTest.php | 6 +-- 8 files changed, 128 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da86dc4..f525138 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v7 with: repository: OpenStatSpec/specification - ref: cd8f198c68b849eb8ed018a894670a0904c2181d + ref: 864e84479f554b8ee250ffed44c4dfb963750d4a path: openstatspec-specification - uses: shivammathur/setup-php@v2 with: @@ -63,7 +63,7 @@ jobs: uses: actions/checkout@v7 with: repository: OpenStatSpec/specification - ref: cd8f198c68b849eb8ed018a894670a0904c2181d + ref: 864e84479f554b8ee250ffed44c4dfb963750d4a path: openstatspec-specification - uses: shivammathur/setup-php@v2 with: @@ -110,7 +110,7 @@ jobs: uses: actions/checkout@v7 with: repository: OpenStatSpec/specification - ref: cd8f198c68b849eb8ed018a894670a0904c2181d + ref: 864e84479f554b8ee250ffed44c4dfb963750d4a path: openstatspec-specification - uses: shivammathur/setup-php@v2 with: @@ -156,7 +156,7 @@ jobs: uses: actions/checkout@v7 with: repository: OpenStatSpec/specification - ref: cd8f198c68b849eb8ed018a894670a0904c2181d + ref: 864e84479f554b8ee250ffed44c4dfb963750d4a path: openstatspec-specification - uses: shivammathur/setup-php@v2 with: @@ -196,6 +196,9 @@ jobs: OPENSTATSPEC_DOLT_PASSWORD: openstatspec OPENSTATSPEC_DOLT_ADMIN_USER: root OPENSTATSPEC_DOLT_ADMIN_PASSWORD: root + OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_DSN: mysql:host=127.0.0.1;port=3306;charset=utf8mb4 + OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_USER: root + OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_PASSWORD: root OPENSTATSPEC_EXPECTED_DOLT_VERSION: ${{ matrix.dolt.version }} steps: - uses: actions/checkout@v7 @@ -203,7 +206,7 @@ jobs: uses: actions/checkout@v7 with: repository: OpenStatSpec/specification - ref: cd8f198c68b849eb8ed018a894670a0904c2181d + ref: 864e84479f554b8ee250ffed44c4dfb963750d4a path: openstatspec-specification - uses: shivammathur/setup-php@v2 with: @@ -218,4 +221,4 @@ jobs: done exit 1 - run: composer install --no-interaction --prefer-dist - - run: vendor/bin/phpunit --filter "DoltSpssRoundTripTest|ServerVersionEvidenceTest|ServerCatalogMigrationTest|StrictSpssDictionaryConformanceTest|OfficialSpssConformanceManifestTest|InPlaceTransformationServiceTest|OfficialInPlaceTransformation01Test|OfficialInPlaceTransformation02Test" + - run: vendor/bin/phpunit --filter "DoltReadOnlyExportTest|DoltSpssRoundTripTest|ServerVersionEvidenceTest|ServerCatalogMigrationTest|StrictSpssDictionaryConformanceTest|OfficialSpssConformanceManifestTest|InPlaceTransformationServiceTest|OfficialInPlaceTransformation01Test|OfficialInPlaceTransformation02Test" diff --git a/CHANGELOG.md b/CHANGELOG.md index fa687fe..bb6fc1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [0.7.0] - Unreleased + +### Changed + +- Pin capabilities and all CI fixtures to released OpenStatSpec specification + `v0.5.0` at immutable commit `864e84479f554b8ee250ffed44c4dfb963750d4a`. + Report `specification_status: released` and select the optional + `database_io_policy: openstatspec-database-io-v1` contract. +- **Breaking:** SAV/ZSAV export is database-read-only, including failures; it + no longer creates operation or fidelity audit records. Initialize or upgrade + the catalogue with `SpssAdapter::migrateCatalog()` on a write-capable deployment + connection before export. Unready catalogues fail with + `catalog_migration_required`; export never initializes or migrates them. +- Export authoritative normative metadata, including transformed labels and + numeric format defaults. Publish output only after a successful temporary-file + write, preserving an existing destination on writer failure. Injected engines + receive the temporary path and must use the Dataset's target format. +- **Breaking:** Default Dolt writes now support exactly 2.2.2 and 2.2.3, not + the previous patch range. No external declaration files are required; unknown + versions (including 2.2.4) fail before mutation. Read-only exports verify + server identity without requiring a supported write version. +- Keep Transformation Plan 0.1/0.2, SPSS Syntax Frontend 0.2, and In-Place + Transformation 0.1/0.2 claims unchanged. Optional 0.3 contracts in the new + specification are not implemented or claimed. +- Run the real SELECT-only Dolt export regression in CI on both exact supported + versions, checking SAV/ZSAV output, failure safety, and unchanged roots/history. + +### Removed + +- **Breaking:** Remove `SpssExportResult::operationId` and its constructor + argument. Export creates no `operation_id`; diagnostics and accepted loss + codes remain available in the result. Import operation IDs are unchanged. + ## [0.6.0] - 2026-08-21 ### Added @@ -89,6 +122,7 @@ specification v0.1.0 at commit `d287c2cde9ade71f04e27dd012caec876901aed5`. [Unreleased]: https://github.com/OpenStatSpec/php/compare/v0.6.0...HEAD +[0.7.0]: https://github.com/OpenStatSpec/php/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/OpenStatSpec/php/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/OpenStatSpec/php/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/OpenStatSpec/php/compare/v0.3.0...v0.4.0 diff --git a/README.md b/README.md index b18946a..c6c53fd 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ It imports an unencrypted SPSS `.sav` or `.zsav` dataset into a relational datab ## Status +PHP v0.7.0 targets released OpenStatSpec specification `v0.5.0` at immutable +commit `864e84479f554b8ee250ffed44c4dfb963750d4a` +(`specification_status: released`). Composer derives the package version from its +Git tag; it is independent of the specification version. + This is an early reference implementation. Its round-trip contract is **semantic**, not byte-identical: supported cases, order, variables, values, dictionary metadata and technical metadata are preserved; compression layout, timestamps and other writer-specific bytes are not promised. SQLite, PostgreSQL 17.x/18.x, MySQL 8.4.x/9.7.x, MariaDB @@ -73,7 +78,10 @@ $export = $adapter->export('survey_2026', '/data/survey-export.sav'); ``` Export needs only read access to an already migrated catalogue and its wide -tables. It reads authoritative normative metadata directly and never initializes, +tables. Initialize or upgrade the catalogue with `SpssAdapter::migrateCatalog()` +using a write-capable deployment connection before using a read-only exporter; +an uninitialized or outdated catalogue fails with `catalog_migration_required`. +It reads authoritative normative metadata directly and never initializes, migrates, synchronizes compatibility tables, or writes database audit records—even on failure. `SpssExportResult` no longer has `operationId`; export creates no `operation_id`. Diagnostics and accepted loss codes remain in the result. @@ -132,7 +140,9 @@ Pass only loss codes consciously accepted for that conversion. `operation_catalo The adapter claims official Transformation Plan 0.1/0.2, SPSS Syntax Frontend 0.2, and In-Place Transformation 0.1/0.2 conformance. Compile an alias-based -frontend request, then bind that alias to the existing dataset at apply time: +frontend request, then bind that alias to the existing dataset at apply time. +The specification pin does not claim support for optional Transformation Plan, +SPSS Syntax Frontend, or In-Place Transformation 0.3. ```php use OpenStatSpec\Frontend\Spss\Request\SpssFrontendRequest; @@ -241,7 +251,8 @@ Run the local gate before committing: ```bash composer install -composer check +# Checkout specification v0.5.0 at 864e84479f554b8ee250ffed44c4dfb963750d4a first. +OPENSTATSPEC_SPECIFICATION_DIR=/path/to/exact-specification-checkout composer check ``` Install the tracked pre-commit hook once per clone: @@ -261,7 +272,9 @@ DDL snapshots. Other engines retain family policies; the packaged Dolt write list is limited to the exact 2.2.2 and 2.2.3 versions tested by existing CI. It does not infer evidence or support for any other patch. -Optional SELECT-only export coverage (also usable on Dolt 2.3.0) creates and +CI runs `DoltReadOnlyExportTest` on both exact Dolt 2.2.2 and 2.2.3 with +root/root admin credentials. Locally opt-in SELECT-only export coverage +(also usable on Dolt 2.3.0) creates and removes its own unique test database and user. It checks SAV/ZSAV output, failure safety and unchanged working/staged roots and history, without expanding write support: diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 517cff8..2dcde38 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,25 +1,44 @@ # PHP adapter release readiness -This page records the release contract for PHP adapter v0.6.0. It does not mean -that a package tag or Packagist publication has happened. +This page records the release contract for PHP adapter v0.7.0. It does not mean +that a package tag or Packagist publication has happened. Composer derives the +package version from the Git tag; `composer.json` has no `version` key. ## Specification pin and claims The machine-readable capability declaration and every CI fixture checkout use -stable OpenStatSpec specification release `v0.3.0` at exact commit -`cd8f198c68b849eb8ed018a894670a0904c2181d`. +released OpenStatSpec specification `v0.5.0` at exact commit +`864e84479f554b8ee250ffed44c4dfb963750d4a`, with +`specification_status: released`. -Release v0.6.0 claims conformance with Transformation Plan 0.1/0.2, SPSS Syntax -Frontend 0.2, and In-Place Transformation 0.1/0.2. It removes the package-local -plan API and the non-standard SPSS schema-edit commands described in the +Release v0.7.0 selects `database_io_policy: openstatspec-database-io-v1`. +SAV/ZSAV export is database-read-only, including failures, and no longer returns +`SpssExportResult::operationId` or writes operation/fidelity audit records. +Initialize or upgrade the catalogue with `SpssAdapter::migrateCatalog()` using a +write-capable deployment connection first; export never initializes or migrates +it and rejects unready catalogues with `catalog_migration_required`. +Exports use authoritative normative metadata and publish files only after a +successful temporary-file write. + +Default Dolt writes support exactly 2.2.2 and 2.2.3 without external declaration +files. Unknown patches, including 2.2.4, fail before mutation. Read-only export +still verifies server identity but does not require a write-version claim. + +Transformation claims remain Transformation Plan 0.1/0.2, SPSS Syntax Frontend +0.2, and In-Place Transformation 0.1/0.2. The specification's optional 0.3 +contracts are not implemented or claimed. Existing target pre-provisioning and +caller-owned Dolt commit rules remain unchanged; see the [transformation migration notes](transformations.md#v060-migration). ## Required gates -Before tagging v0.6.0: +Before tagging v0.7.0: -1. Run `composer check` with `OPENSTATSPEC_SPECIFICATION_DIR` pointing to the - exact pinned checkout. +1. Verify `git rev-parse HEAD` in the specification checkout equals + `864e84479f554b8ee250ffed44c4dfb963750d4a`, and the published `v0.5.0` tag + resolves to that commit. Run `composer check` with + `OPENSTATSPEC_SPECIFICATION_DIR` pointing to that exact checkout. The tracked + pre-commit hook repeats the gate on the LF-normalized staged archive. 2. Run every official transformation suite: ```bash vendor/bin/phpunit tests/Transformation/Conformance @@ -36,10 +55,21 @@ Before tagging v0.6.0: catalog targets on those three profiles. 5. Confirm live Dolt evidence proves stable branch/HEAD, a clean pre-apply working set, no adapter-created commit, and caller-owned commit policy. + Run `DoltReadOnlyExportTest` on exact 2.2.2 and 2.2.3 with + `OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_DSN=mysql:host=127.0.0.1;port=3306;charset=utf8mb4`, + `OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_USER=root`, and + `OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_PASSWORD=root`. It creates and removes only + its own isolated database/user and proves SELECT-only SAV/ZSAV export, + failure safety, and unchanged working/staged roots and history. 6. Confirm GitHub Actions passes the full PHP 8.4/8.5 jobs plus every PostgreSQL, MySQL, MariaDB, and Dolt matrix entry. Each service filter must - include both official in-place test classes. -7. Confirm the changelog and release notes describe the same specification - release, commit, breaking removals, and pre-provisioning rules. -8. Create annotated tag `v0.6.0` on the reviewed `main` commit, verify the tag, - publish the GitHub release, and confirm Packagist installation. + include both official in-place test classes; the Dolt filter must also + include `DoltReadOnlyExportTest`. +7. Confirm README, changelog, and release notes agree on the exact specification + pin, breaking export changes, initialization requirement, and Dolt defaults. + Run `composer install --dry-run --no-dev` and + `composer archive --format=zip --dir=/tmp/openstatspec-php-v070-package`; + inspect the archive without publishing it. No separate build is required. +8. Publication is a separate maintainer action: create and verify annotated + tag `v0.7.0` on the reviewed `main` commit, publish the GitHub release, and + confirm Packagist installation. Do not infer publication from this checklist. diff --git a/src/Core/CapabilityDeclaration.php b/src/Core/CapabilityDeclaration.php index 98cbf91..a6ab29f 100644 --- a/src/Core/CapabilityDeclaration.php +++ b/src/Core/CapabilityDeclaration.php @@ -18,9 +18,9 @@ /** Machine-readable SPSS 1.0 and SQL-profile capability declaration. */ final readonly class CapabilityDeclaration implements JsonSerializable { - public const SPECIFICATION_STATUS = 'stable'; - public const SPECIFICATION_RELEASE = 'v0.3.0'; - public const SPECIFICATION_COMMIT = 'cd8f198c68b849eb8ed018a894670a0904c2181d'; + public const SPECIFICATION_STATUS = 'released'; + public const SPECIFICATION_RELEASE = 'v0.5.0'; + public const SPECIFICATION_COMMIT = '864e84479f554b8ee250ffed44c4dfb963750d4a'; private Connection $connection; diff --git a/tests/Core/CapabilityDeclarationTest.php b/tests/Core/CapabilityDeclarationTest.php index 00a1ca4..cbeb396 100644 --- a/tests/Core/CapabilityDeclarationTest.php +++ b/tests/Core/CapabilityDeclarationTest.php @@ -16,10 +16,10 @@ public function testDeclarationIsMachineReadableAndIncludesEveryProfileLimit(): $pdo = new \PDO('sqlite::memory:'); $declaration = (new CapabilityDeclaration($pdo, new PhpSpssEngine()))->toArray(); self::assertSame('openstatspec-database-io-v1', $declaration['database_io_policy']); - self::assertSame('stable', $declaration['specification_status']); - self::assertSame('v0.3.0', $declaration['specification_release']); + self::assertSame('released', $declaration['specification_status']); + self::assertSame('v0.5.0', $declaration['specification_release']); self::assertSame(CapabilityDeclaration::SPECIFICATION_RELEASE, $declaration['specification_release']); - self::assertSame('cd8f198c68b849eb8ed018a894670a0904c2181d', $declaration['specification_commit']); + self::assertSame('864e84479f554b8ee250ffed44c4dfb963750d4a', $declaration['specification_commit']); self::assertSame(CapabilityDeclaration::SPECIFICATION_COMMIT, $declaration['specification_commit']); self::assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $declaration['specification_commit']); self::assertSame(['import', 'export', 'semantic_round_trip'], $declaration['directions']); @@ -76,7 +76,7 @@ public function testDeclarationIsMachineReadableAndIncludesEveryProfileLimit(): $profile = $declaration['sql_profiles'][$name]; self::assertSame($name, $profile['profile']); self::assertSame(CapabilityDeclaration::SPECIFICATION_COMMIT, $profile['specification_commit']); - self::assertSame('stable', $profile['specification_status']); + self::assertSame('released', $profile['specification_status']); self::assertSame(CapabilityDeclaration::SPECIFICATION_RELEASE, $profile['specification_release']); self::assertGreaterThan(0, $profile['theoretical_limits']['maximum_value_bytes']); self::assertNotSame('', $profile['claimed_server_versions']); diff --git a/tests/Release/CiTransformationGateTest.php b/tests/Release/CiTransformationGateTest.php index 256ac72..a7ff002 100644 --- a/tests/Release/CiTransformationGateTest.php +++ b/tests/Release/CiTransformationGateTest.php @@ -32,6 +32,19 @@ public function testMySqlOfficialTransformationGateHasIsolationCredentialsAndRej ); } + public function testDoltSelectOnlyExportGateHasAdminCredentials(): void + { + $workflow = file_get_contents(dirname(__DIR__, 2) . '/.github/workflows/ci.yml'); + self::assertIsString($workflow); + $job = $this->job($workflow, 'dolt-integration'); + $environment = $this->environment($job, 6); + + self::assertSame('mysql:host=127.0.0.1;port=3306;charset=utf8mb4', $environment['OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_DSN'] ?? null); + self::assertSame('root', $environment['OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_USER'] ?? null); + self::assertSame('root', $environment['OPENSTATSPEC_DOLT_READ_ONLY_ADMIN_PASSWORD'] ?? null); + self::assertMatchesRegularExpression('/vendor\/bin\/phpunit --filter "[^"\r\n]*DoltReadOnlyExportTest/', $job); + } + private function job(string $workflow, string $name): string { $matched = preg_match( diff --git a/tests/Release/SpecificationPinTest.php b/tests/Release/SpecificationPinTest.php index 8e459a2..e4cfe15 100644 --- a/tests/Release/SpecificationPinTest.php +++ b/tests/Release/SpecificationPinTest.php @@ -10,11 +10,11 @@ final class SpecificationPinTest extends TestCase { - public function testEverySpecificationCheckoutUsesTheStableReleaseCommit(): void + public function testEverySpecificationCheckoutUsesTheReleasedSpecificationCommit(): void { - self::assertSame('v0.3.0', CapabilityDeclaration::SPECIFICATION_RELEASE); + self::assertSame('v0.5.0', CapabilityDeclaration::SPECIFICATION_RELEASE); self::assertSame( - 'cd8f198c68b849eb8ed018a894670a0904c2181d', + '864e84479f554b8ee250ffed44c4dfb963750d4a', CapabilityDeclaration::SPECIFICATION_COMMIT, ); $workflowFiles = glob(dirname(__DIR__, 2) . '/.github/workflows/*.{yml,yaml}', GLOB_BRACE);