diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ae8d63..d28063f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,7 @@ jobs: coverage: none extensions: pdo_pgsql, pdo_sqlite, bcmath, mbstring, zlib - run: composer install --no-interaction --prefer-dist - - run: vendor/bin/phpunit --filter "PostgreSqlSpssRoundTripTest|ServerVersionEvidenceTest|ServerCatalogMigrationTest|StrictSpssDictionaryConformanceTest|OfficialSpssConformanceManifestTest" + - run: vendor/bin/phpunit --filter "PostgreSqlSpssRoundTripTest|ServerVersionEvidenceTest|ServerCatalogMigrationTest|StrictSpssDictionaryConformanceTest|OfficialSpssConformanceManifestTest|InPlaceTransformationServiceTest" mysql-integration: name: MySQL ${{ matrix.mysql }} integration / PHP ${{ matrix.php }} @@ -115,7 +115,7 @@ jobs: coverage: none extensions: pdo_mysql, pdo_sqlite, bcmath, mbstring, zlib - run: composer install --no-interaction --prefer-dist - - run: vendor/bin/phpunit --filter "MySqlSpssRoundTripTest|ServerVersionEvidenceTest|ServerCatalogMigrationTest|StrictSpssDictionaryConformanceTest|OfficialSpssConformanceManifestTest" + - run: vendor/bin/phpunit --filter "MySqlSpssRoundTripTest|ServerVersionEvidenceTest|ServerCatalogMigrationTest|StrictSpssDictionaryConformanceTest|OfficialSpssConformanceManifestTest|InPlaceTransformationServiceTest" mariadb-integration: name: MariaDB ${{ matrix.mariadb }} integration / PHP ${{ matrix.php }} @@ -159,7 +159,7 @@ jobs: coverage: none extensions: pdo_mysql, pdo_sqlite, bcmath, mbstring, zlib - run: composer install --no-interaction --prefer-dist - - run: vendor/bin/phpunit --filter "MariaDbSpssRoundTripTest|ServerVersionEvidenceTest|ServerCatalogMigrationTest|StrictSpssDictionaryConformanceTest|OfficialSpssConformanceManifestTest" + - run: vendor/bin/phpunit --filter "MariaDbSpssRoundTripTest|ServerVersionEvidenceTest|ServerCatalogMigrationTest|StrictSpssDictionaryConformanceTest|OfficialSpssConformanceManifestTest|InPlaceTransformationServiceTest" dolt-integration: name: Dolt ${{ matrix.dolt.version }} integration / PHP ${{ matrix.php }} @@ -189,6 +189,8 @@ jobs: OPENSTATSPEC_DOLT_DSN: mysql:host=127.0.0.1;port=3306;dbname=openstatspec;charset=utf8mb4 OPENSTATSPEC_DOLT_USER: openstatspec OPENSTATSPEC_DOLT_PASSWORD: openstatspec + OPENSTATSPEC_DOLT_ADMIN_USER: root + OPENSTATSPEC_DOLT_ADMIN_PASSWORD: root OPENSTATSPEC_EXPECTED_DOLT_VERSION: ${{ matrix.dolt.version }} steps: - uses: actions/checkout@v7 @@ -211,4 +213,4 @@ jobs: done exit 1 - run: composer install --no-interaction --prefer-dist - - run: vendor/bin/phpunit --filter "DoltSpssRoundTripTest|ServerVersionEvidenceTest|ServerCatalogMigrationTest|StrictSpssDictionaryConformanceTest|OfficialSpssConformanceManifestTest" + - run: vendor/bin/phpunit --filter "DoltSpssRoundTripTest|ServerVersionEvidenceTest|ServerCatalogMigrationTest|StrictSpssDictionaryConformanceTest|OfficialSpssConformanceManifestTest|InPlaceTransformationServiceTest" diff --git a/.gitignore b/.gitignore index 6afcd70..e7656e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor/ +/.superpowers/ /.php-cs-fixer.cache /.phpunit.result.cache /.phpunit.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e015310..5397c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ documentation with the separately versioned OpenStatSpec package. - Made the transformation capability boundary explicit: the current PHP API uses the package-local legacy `openstatspec-transformation-plan-v1` contract and does not yet claim official Transformation Plan or SPSS Frontend profile 0.1/0.2 conformance. +- Added the PHP in-place transformation service-matrix evidence gate. SQLite + runs locally; locally unconfigured PostgreSQL, MySQL, MariaDB, and Dolt + profiles skip, while CI configures and executes every service profile. The + gate proves existing-target dataset, physical-table, and variable identity; + it restricts implicit numeric target creation to SQLite/PostgreSQL and + requires MySQL/MariaDB/Dolt targets to be created and catalogued first. + This remains adapter evidence under the legacy contract, not an official + OpenStatSpec Transformation Plan 1.0 profile claim. + ## [0.4.0] - 2026-07-31 ### Added diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 540cc7f..6422791 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -21,10 +21,25 @@ Before tagging a release: 1. Run `composer check` with `OPENSTATSPEC_SPECIFICATION_DIR` pointing to the exact `v0.3.0` checkout. -2. Confirm GitHub Actions passes on PHP 8.4 and 8.5 plus the PostgreSQL, - MySQL, MariaDB, and Dolt service matrices. -3. Confirm the changelog and release notes describe the same specification +2. Run the focused service evidence command: + ```bash + OPENSTATSPEC_SPECIFICATION_DIR=/path/to/openstatspec-specification \ + vendor/bin/phpunit tests/Integration/InPlaceTransformationServiceTest.php + ``` + Locally, SQLite executes and an unconfigured PostgreSQL, MySQL, MariaDB, or + Dolt profile skips; CI must configure and execute every one of those + service profiles. +3. Confirm that the existing-target service evidence preserves the existing + dataset UUID, physical table, and variable identities without copied or + rollback state. Confirm that implicit numeric target creation is restricted + to SQLite and PostgreSQL, while MySQL, MariaDB, and Dolt reject it before + mutation and require a pre-created, catalogued target. +4. Confirm GitHub Actions passes on PHP 8.4 and 8.5 plus the PostgreSQL, + MySQL, MariaDB, and Dolt service matrices. This is adapter evidence under + the legacy `openstatspec-transformation-plan-v1` contract, not an official + OpenStatSpec Transformation Plan 1.0 profile claim. +5. Confirm the changelog and release notes describe the same specification release and commit. -4. Create an annotated `vX.Y.Z` tag on the reviewed `main` commit and verify +6. Create an annotated `vX.Y.Z` tag on the reviewed `main` commit and verify that the tag points to that commit before publishing the GitHub release. -5. Confirm the resulting Composer package is installable from Packagist. +7. Confirm the resulting Composer package is installable from Packagist. diff --git a/docs/superpowers/plans/2026-08-12-php-inplace-service-matrix.md b/docs/superpowers/plans/2026-08-12-php-inplace-service-matrix.md new file mode 100644 index 0000000..4469cd3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-php-inplace-service-matrix.md @@ -0,0 +1,89 @@ +# PHP In-Place Transformation Service Matrix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an integration evidence gate proving the PHP adapter's in-place transformation behavior and target-creation boundary for every claimed SQL profile, using major/minor `.x` version ranges. + +**Architecture:** Extend the existing PHPUnit integration conventions with one profile-aware test class. Reuse the production executor and normative catalog APIs; create isolated deterministic fixtures per test, and compare database/Dolt state before and after preflight rejection. No production behavior change is authorized unless a failing evidence test exposes a real defect. + +**Tech Stack:** PHP 8.4/8.5, PHPUnit, PDO, existing OpenStatSpec normative catalog and transformation services, SQLite, PostgreSQL, MySQL, MariaDB, Dolt, GitHub Actions. + +## Global Constraints + +- PHP adapter only; Python remains a separate workstream. +- Test every configured profile: SQLite, PostgreSQL, MySQL, MariaDB, and Dolt. +- Missing local service configuration skips only that profile; CI service jobs must supply configuration and execute it. +- Database version expectations use major/minor `.x` ranges (`17.x`, `8.4.x`, `11.4.x`, `2.2.x`), never fixed patch versions. +- Use unique fixture names/UUIDs per test; do not drop shared schemas or hide observed state with cleanup. +- Do not create snapshots, staging/copy tables, rollback tables, Dolt commits, branch changes, resets, or other artifacts as part of evidence setup or assertions. +- Preserve the legacy transformation-contract disclaimer; do not claim an official Transformation Plan 1.0 profile. + +--- + +## Task 1: Add the profile-aware fixture and existing-target evidence test + +**Files:** +- Create: `tests/Integration/InPlaceTransformationServiceTest.php` +- Reference: `tests/Integration/ServerVersionEvidenceTest.php` +- Reference: `tests/Integration/OfficialSpssConformanceManifestTest.php` +- Reference: `src/Transformation/Execution/InPlaceTransformationExecutor.php` + +- [ ] Inspect existing integration connection helpers, catalog setup, identifiers, and cleanup conventions. +- [ ] Write the provider/configuration test first and run the focused test to establish the expected failure. +- [ ] Add a profile provider covering SQLite plus the `OPENSTATSPEC_PG_*`, `OPENSTATSPEC_MYSQL_*`, `OPENSTATSPEC_MARIADB_*`, and `OPENSTATSPEC_DOLT_*` environment families, with expected-version checks as major/minor `.x` ranges. +- [ ] Add deterministic fixture setup using dataset UUID `018f47f2-8b6a-7c3d-9e1f-123456789abc`, source values `[1, 2, 3, 9, NULL]`, destination values `[-1, -1, -1, -1, -1]`, and profile-aware quoted identifiers. +- [ ] Add the existing-target test: recode to `[10, 20, 20, 9, 99]`, update the variable label and value labels, and assert result dataset UUID/plan hash, unchanged physical identity, unchanged dataset/table/variable identity, catalog metadata, and absence of snapshot/staging/copied/derived/rollback/parallel-history tables. +- [ ] Run the focused SQLite test and relevant static checks; commit as `test: add PHP in-place service evidence`. + +## Task 2: Add the target-creation boundary evidence + +**Files:** +- Modify: `tests/Integration/InPlaceTransformationServiceTest.php` +- Reference: `src/Transformation/Execution/InPlaceTransformationExecutor.php` +- Reference: `src/Transformation/Execution/*` + +- [ ] Add a test for SQLite and PostgreSQL where a new numeric target is created in the same wide table; assert the new catalog variable, physical column, recoded values, and unchanged dataset/table identity. +- [ ] Add the equivalent MySQL, MariaDB, and Dolt test; capture rows, catalog rows, physical columns, dataset count, and table names before execution. +- [ ] Assert those profiles reject the plan during preflight with the documented capability exception and leave every captured value byte-for-byte equivalent. +- [ ] Run the focused test against SQLite and any configured service profiles; fix only defects demonstrated by the evidence test, adding regression coverage for each production fix. +- [ ] Commit as `test: prove in-place target capability boundary` (or a narrowly scoped `fix:` commit if production code is required). + +## Task 3: Add Dolt repository-state assertions + +**Files:** +- Modify: `tests/Integration/InPlaceTransformationServiceTest.php` +- Reference: `tests/Transformation/Execution/DoltGuardTest.php` +- Reference: `tests/Transformation/Execution/DoltHeadGuardTest.php` + +- [ ] Record Dolt branch, HEAD, commit/history identity, and clean working-set status before the successful existing-target apply. +- [ ] Assert branch and HEAD remain unchanged, the expected inspectable working-set diff is present, and no commit or reset occurs. +- [ ] Record the same state before create-target rejection and assert branch, HEAD, history, status, rows, catalog, schema, and table names are unchanged afterward. +- [ ] Run the Dolt-focused test when `OPENSTATSPEC_DOLT_*` is configured and keep it skipped, not falsely green, when absent. +- [ ] Commit as `test: assert Dolt in-place repository evidence`. + +## Task 4: Wire the evidence gate into CI + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] Add `InPlaceTransformationServiceTest` to the focused PHPUnit filter for PostgreSQL, MySQL, MariaDB, and Dolt jobs without removing existing filters. +- [ ] Verify the normal PHP 8.4/8.5 job still runs the class through the complete Composer test suite with SQLite. +- [ ] Run YAML/configuration validation and inspect the diff; commit as `ci: run PHP in-place evidence across services`. + +## Task 5: Document and verify + +**Files:** +- Modify: `docs/transformations.md` +- Modify: `docs/release-readiness.md` +- Modify: `CHANGELOG.md` + +- [ ] Document the service-matrix command, profile skip/CI execution rule, existing-target identity guarantee, and unsupported target-creation boundary. +- [ ] Keep wording explicit that this is adapter evidence and does not establish an official Transformation Plan 1.0 profile. +- [ ] Run focused PHPUnit, full `composer test`, `composer validate`, `composer lint`, `composer analyse`, and `git diff --check`. +- [ ] Review the complete diff for fixture isolation, no destructive cleanup, and coverage of every approved design requirement; commit as `docs: document PHP in-place evidence gate`. + +## Final verification and handoff + +- [ ] Confirm the worktree is clean and list implementation commits. +- [ ] Report which local profiles ran versus skipped due to missing configuration. +- [ ] If checks pass, request the local code-review loop before publishing a PR. diff --git a/docs/transformations.md b/docs/transformations.md index cd83a7b..1a2b1ca 100644 --- a/docs/transformations.md +++ b/docs/transformations.md @@ -131,6 +131,33 @@ as supported for every implemented SQL profile and states these target-creation boundaries. Dolt additionally reports its clean-working-set and stable branch/HEAD guard. +## Service-matrix evidence gate + +Run the PHP adapter's in-place evidence matrix with the pinned specification +checkout: + +```bash +OPENSTATSPEC_SPECIFICATION_DIR=/path/to/openstatspec-specification \ + vendor/bin/phpunit tests/Integration/InPlaceTransformationServiceTest.php +``` + +SQLite runs locally. PostgreSQL, MySQL, MariaDB, and Dolt run only when their +respective `OPENSTATSPEC_PG_*`, `OPENSTATSPEC_MYSQL_*`, +`OPENSTATSPEC_MARIADB_*`, or `OPENSTATSPEC_DOLT_*` configuration is supplied; +an absent local profile is reported as skipped. CI supplies every service +profile configuration, so a skipped configured service is not acceptable CI +evidence. + +For every configured profile, the existing-target evidence proves that +recode plus label changes preserve the dataset UUID, physical table identity, +and existing variable identities, with no copied, staging, snapshot, rollback, +or parallel-history table. Numeric implicit target creation is evidenced only +on SQLite and PostgreSQL. MySQL, MariaDB, and Dolt reject it before mutation; +their target variable must be created and catalogued by deployment workflow +before the recode. This is PHP adapter evidence only: it retains the legacy +`openstatspec-transformation-plan-v1` contract and does not establish an +official OpenStatSpec Transformation Plan 1.0 profile. + ## Minimal PHP flow ```php diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php new file mode 100644 index 0000000..0886a29 --- /dev/null +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -0,0 +1,1512 @@ + */ + private array $doltTestDatabases = []; + + private ?string $doltDatabaseRunId = null; + + private int $doltDatabaseSequence = 0; + + protected function tearDown(): void + { + try { + foreach (array_reverse($this->doltTestDatabases) as $testDatabase) { + $testDatabase['admin']->exec( + 'DROP DATABASE ' . $this->quoteDoltDatabaseName($testDatabase['database']), + ); + } + } finally { + $this->doltTestDatabases = []; + $this->activeFixture = null; + parent::tearDown(); + } + } + + /** @return iterable, string|null}> */ + public static function services(): iterable + { + yield 'SQLite' => ['sqlite', null, 'sqlite', ['>=3.24.0 <4.0.0'], null]; + yield 'PostgreSQL' => ['postgresql', 'OPENSTATSPEC_PG', 'pgsql', ['17.x', '18.x'], 'OPENSTATSPEC_EXPECTED_POSTGRES_VERSION']; + yield 'MySQL' => ['mysql', 'OPENSTATSPEC_MYSQL', 'mysql', ['8.4.x', '9.7.x'], 'OPENSTATSPEC_EXPECTED_MYSQL_VERSION']; + yield 'MariaDB' => ['mariadb', 'OPENSTATSPEC_MARIADB', 'mysql', ['11.4.x', '11.8.x', '12.3.x'], 'OPENSTATSPEC_EXPECTED_MARIADB_VERSION']; + yield 'Dolt' => ['dolt', 'OPENSTATSPEC_DOLT', 'mysql', ['2.2.x'], 'OPENSTATSPEC_EXPECTED_DOLT_VERSION']; + } + + /** @return iterable, string|null}> */ + public static function createTargetCapableServices(): iterable + { + foreach (self::services() as $label => $service) { + if (in_array($service[0], ['sqlite', 'postgresql'], true)) { + yield $label => $service; + } + } + } + + /** @return iterable, string|null}> */ + public static function createTargetRejectedServices(): iterable + { + foreach (self::services() as $label => $service) { + if (in_array($service[0], ['mysql', 'mariadb', 'dolt'], true)) { + yield $label => $service; + } + } + } + + /** @param list $expectedVersionFamilies */ + #[DataProvider('services')] + public function testConfiguredServiceMatchesExpectedProfileAndVersionFamily( + string $expectedProfile, + ?string $environmentPrefix, + string $driver, + array $expectedVersionFamilies, + ?string $expectedVersionEnvironment, + ): void { + $connection = new Connection($this->servicePdo($expectedProfile, $environmentPrefix, $driver)); + + self::assertSame($expectedProfile, $connection->profileName); + self::assertTrue($connection->claimedSupported); + $this->assertExpectedVersionFamily($connection, $expectedVersionFamilies, $expectedVersionEnvironment); + } + + public function testFixtureInstallationsUseDistinctDeterministicNamespaces(): void + { + $pdo = $this->servicePdo('sqlite', null, 'sqlite'); + $connection = new Connection($pdo); + $first = $this->installFixture($pdo, $connection); + $second = null; + + try { + try { + $second = $this->installFixture($pdo, $connection); + } catch (RuntimeException $exception) { + self::fail('A second independent fixture installation collided: ' . $exception->getMessage()); + } + + self::assertNotSame($first['dataset_id'], $second['dataset_id']); + self::assertNotSame($first['table_name'], $second['table_name']); + self::assertSame(2, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset', [])); + } finally { + if (is_array($second)) { + $this->purgeFixture($pdo, $connection, $second); + } + $this->purgeFixture($pdo, $connection, $first); + } + } + + /** @param list $expectedVersionFamilies */ + #[DataProvider('services')] + public function testExistingTargetTransformationPreservesPhysicalIdentityInPlace( + string $expectedProfile, + ?string $environmentPrefix, + string $driver, + array $expectedVersionFamilies, + ?string $expectedVersionEnvironment, + ): void { + unset($expectedVersionFamilies, $expectedVersionEnvironment); + $pdo = $this->servicePdo($expectedProfile, $environmentPrefix, $driver); + $connection = new Connection($pdo); + $fixture = $this->installFixture($pdo, $connection); + + try { + self::assertSame(self::DATASET_ID, $fixture['dataset_id']); + $datasetCountBefore = (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset', []); + $doltBefore = $this->doltRepositoryEvidence($pdo, $connection); + if ($expectedProfile === 'dolt') { + self::assertNotNull($doltBefore); + self::assertNotSame('', $doltBefore['branch']); + self::assertNotSame('', $doltBefore['head']); + self::assertSame($doltBefore['head'], $doltBefore['history'][0] ?? null); + self::assertSame([], $doltBefore['status']); + self::assertSame([], $doltBefore['working_diff']); + } + $plan = $this->existingTargetPlan(); + $result = (new InPlaceTransformationExecutor($connection))->execute($plan); + + $doltAfter = $this->doltRepositoryEvidence($pdo, $connection); + if ($expectedProfile === 'dolt') { + self::assertNotNull($doltBefore); + self::assertNotNull($doltAfter); + self::assertSame($doltBefore['branch'], $doltAfter['branch'], 'The active Dolt branch changed.'); + self::assertSame($doltBefore['head'], $doltAfter['head'], 'A Dolt commit or HEAD-moving reset occurred.'); + self::assertSame($doltBefore['history'], $doltAfter['history'], 'The reachable Dolt commit history changed.'); + self::assertSame( + [ + ['table_name' => 'inplace_existing_target_dolt', 'staged' => false, 'status' => 'modified'], + ['table_name' => 'value_label', 'staged' => false, 'status' => 'modified'], + ['table_name' => 'value_label_set', 'staged' => false, 'status' => 'modified'], + ['table_name' => 'variable', 'staged' => false, 'status' => 'modified'], + ['table_name' => 'variable_value_label_set', 'staged' => false, 'status' => 'modified'], + ], + $doltAfter['status'], + 'A reset, staging operation, or unexpected table mutation changed the Dolt working set.', + ); + self::assertSame( + [ + ['table_name' => 'inplace_existing_target_dolt', 'data_change' => true, 'schema_change' => false], + ['table_name' => 'value_label', 'data_change' => true, 'schema_change' => false], + ['table_name' => 'value_label_set', 'data_change' => true, 'schema_change' => false], + ['table_name' => 'variable', 'data_change' => true, 'schema_change' => false], + ['table_name' => 'variable_value_label_set', 'data_change' => true, 'schema_change' => false], + ], + $doltAfter['working_diff'], + 'The successful transformation must remain inspectable as an unstaged data-only Dolt diff.', + ); + } + + self::assertSame($fixture['dataset_id'], $result->datasetId()); + self::assertSame($plan->hash(), $result->planHash()); + self::assertSame(3, $result->operationCount()); + + self::assertSame($fixture['dataset'], $this->datasetRow($pdo)); + self::assertSame($fixture['variables'], $this->variableIdentityRows($pdo)); + self::assertSame($fixture['columns'], $this->tableColumns($pdo, $connection, $fixture['table_name'])); + self::assertSame($fixture['tables'], $this->tableNames($pdo)); + + self::assertSame( + [10.0, 20.0, 20.0, 9.0, 99.0], + $this->numericColumn( + $pdo, + $connection, + $fixture['table_name'], + 'destination', + ), + ); + self::assertSame( + [ + [ + 'variable_id' => $fixture['destination_variable_id'], + 'variable_label' => 'Recoded destination', + ], + ], + $this->rows( + $pdo, + 'SELECT variable_id, variable_label FROM variable WHERE dataset_id = ? AND source_name = ?', + [$fixture['dataset_id'], 'Destination'], + ), + ); + self::assertSame( + [ + ['ordinal' => 1, 'code_kind' => 'numeric', 'value' => 10.0, 'label' => 'Ten'], + ['ordinal' => 2, 'code_kind' => 'numeric', 'value' => 20.0, 'label' => 'Twenty'], + ['ordinal' => 3, 'code_kind' => 'numeric', 'value' => 99.0, 'label' => 'Missing source'], + ], + $this->destinationValueLabels($pdo), + ); + self::assertSame($datasetCountBefore, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset', [])); + self::assertSame(2, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM variable WHERE dataset_id = ?', [$fixture['dataset_id']])); + $this->assertNoArtifactTables($fixture['tables']); + } finally { + $this->purgeFixture($pdo, $connection, $fixture); + } + } + + /** @param list $expectedVersionFamilies */ + #[DataProvider('createTargetCapableServices')] + public function testCreateTargetTransformationCreatesNumericTargetInPlace( + string $expectedProfile, + ?string $environmentPrefix, + string $driver, + array $expectedVersionFamilies, + ?string $expectedVersionEnvironment, + ): void { + unset($expectedVersionFamilies, $expectedVersionEnvironment); + $pdo = $this->servicePdo($expectedProfile, $environmentPrefix, $driver); + $connection = new Connection($pdo); + $fixture = $this->installFixture($pdo, $connection); + + try { + $datasetCountBefore = (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset', []); + $plan = $this->createTargetPlan(); + $result = (new InPlaceTransformationExecutor($connection))->execute($plan); + + self::assertSame($fixture['dataset_id'], $result->datasetId()); + self::assertSame($plan->hash(), $result->planHash()); + self::assertSame(3, $result->operationCount()); + + self::assertSame($fixture['dataset'], $this->datasetRow($pdo)); + self::assertSame($fixture['tables'], $this->tableNames($pdo)); + self::assertSame( + [...$fixture['columns'], 'createdtarget'], + $this->tableColumns($pdo, $connection, $fixture['table_name']), + ); + $this->assertProfileNumericColumnType($pdo, $connection, $fixture['table_name'], 'createdtarget'); + self::assertSame( + [ + [ + '__case_ordinal' => 1, + 'source_value' => 1.0, + 'destination' => -1.0, + 'createdtarget' => 10.0, + ], + [ + '__case_ordinal' => 2, + 'source_value' => 2.0, + 'destination' => -1.0, + 'createdtarget' => 20.0, + ], + [ + '__case_ordinal' => 3, + 'source_value' => 3.0, + 'destination' => -1.0, + 'createdtarget' => 20.0, + ], + [ + '__case_ordinal' => 4, + 'source_value' => 9.0, + 'destination' => -1.0, + 'createdtarget' => 9.0, + ], + [ + '__case_ordinal' => 5, + 'source_value' => null, + 'destination' => -1.0, + 'createdtarget' => 99.0, + ], + ], + $this->tableRows( + $pdo, + $connection, + $fixture['table_name'], + ['__case_ordinal', 'source_value', 'destination', 'createdtarget'], + ), + ); + self::assertSame($fixture['variables'], array_slice($this->variableIdentityRows($pdo), 0, 2)); + self::assertSame(3, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM variable WHERE dataset_id = ?', [$fixture['dataset_id']])); + $createdTargetRows = $this->rows( + $pdo, + 'SELECT variable_id, source_ordinal, source_name, physical_name, storage_kind, declared_string_width, variable_label ' + . 'FROM variable WHERE dataset_id = ? AND source_name = ?', + [$fixture['dataset_id'], 'CreatedTarget'], + ); + self::assertCount(1, $createdTargetRows); + self::assertMatchesRegularExpression('/^[0-9a-f-]{36}$/i', (string) $createdTargetRows[0]['variable_id']); + self::assertSame( + [ + 'source_ordinal' => 3, + 'source_name' => 'CreatedTarget', + 'physical_name' => 'createdtarget', + 'storage_kind' => 'numeric', + 'declared_string_width' => null, + 'variable_label' => 'Recoded created target', + ], + [ + 'source_ordinal' => (int) $createdTargetRows[0]['source_ordinal'], + 'source_name' => (string) $createdTargetRows[0]['source_name'], + 'physical_name' => (string) $createdTargetRows[0]['physical_name'], + 'storage_kind' => (string) $createdTargetRows[0]['storage_kind'], + 'declared_string_width' => $createdTargetRows[0]['declared_string_width'], + 'variable_label' => (string) $createdTargetRows[0]['variable_label'], + ], + ); + self::assertSame( + [ + ['ordinal' => 1, 'code_kind' => 'numeric', 'value' => 10.0, 'label' => 'Ten'], + ['ordinal' => 2, 'code_kind' => 'numeric', 'value' => 20.0, 'label' => 'Twenty'], + ['ordinal' => 3, 'code_kind' => 'numeric', 'value' => 99.0, 'label' => 'Missing source'], + ], + $this->valueLabelsForVariable($pdo, 'CreatedTarget'), + ); + self::assertSame($datasetCountBefore, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset', [])); + self::assertSame(count($fixture['tables']), count($this->tableNames($pdo))); + $this->assertNoArtifactTables($fixture['tables']); + } finally { + $this->purgeFixture($pdo, $connection, $fixture); + } + } + + /** @param list $expectedVersionFamilies */ + #[DataProvider('createTargetRejectedServices')] + public function testCreateTargetTransformationRejectsNonAtomicProfilesWithoutChangingState( + string $expectedProfile, + ?string $environmentPrefix, + string $driver, + array $expectedVersionFamilies, + ?string $expectedVersionEnvironment, + ): void { + unset($expectedVersionFamilies, $expectedVersionEnvironment); + $pdo = $this->servicePdo($expectedProfile, $environmentPrefix, $driver); + $connection = new Connection($pdo); + $fixture = $this->installFixture($pdo, $connection); + + try { + $before = [ + 'catalog' => $this->fullCatalogSnapshot($pdo), + 'rows' => $this->rawTableRows( + $pdo, + $connection, + $fixture['table_name'], + ['__case_ordinal', 'source_value', 'destination'], + ), + 'columns' => $this->tableColumns($pdo, $connection, $fixture['table_name']), + 'tables' => $this->tableNames($pdo), + 'table_count' => count($this->tableNames($pdo)), + 'dataset_count' => (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset', []), + 'variable_count' => (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM variable', []), + 'dolt_repository' => $this->doltRepositoryEvidence($pdo, $connection), + ]; + + try { + (new InPlaceTransformationExecutor($connection))->execute($this->createTargetPlan()); + self::fail('Expected non-atomic service profile to reject implicit target creation during preflight.'); + } catch (UnsupportedOperation $exception) { + self::assertSame(DiagnosticCode::TargetCapabilityExceeded, $exception->diagnosticCode); + self::assertSame( + sprintf( + '%s cannot atomically add a new INTO target to an existing wide table; register the target variable first.', + $expectedProfile, + ), + $exception->getMessage(), + ); + } + + self::assertSame($before['catalog'], $this->fullCatalogSnapshot($pdo)); + self::assertSame( + $before['rows'], + $this->rawTableRows( + $pdo, + $connection, + $fixture['table_name'], + ['__case_ordinal', 'source_value', 'destination'], + ), + ); + self::assertSame($before['columns'], $this->tableColumns($pdo, $connection, $fixture['table_name'])); + self::assertSame($before['tables'], $this->tableNames($pdo)); + self::assertSame($before['table_count'], count($this->tableNames($pdo))); + self::assertSame($before['dataset_count'], (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset', [])); + self::assertSame($before['variable_count'], (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM variable', [])); + self::assertSame($before['dolt_repository'], $this->doltRepositoryEvidence($pdo, $connection)); + $this->assertNoArtifactTables($fixture['tables']); + } finally { + $this->purgeFixture($pdo, $connection, $fixture); + } + } + + /** @param list $expectedVersionFamilies */ + #[DataProvider('services')] + public function testFixtureSetupRejectsDirtyDeterministicNamespaceWithoutDeletingIt( + string $expectedProfile, + ?string $environmentPrefix, + string $driver, + array $expectedVersionFamilies, + ?string $expectedVersionEnvironment, + ): void { + unset($expectedVersionFamilies, $expectedVersionEnvironment); + $pdo = $this->servicePdo($expectedProfile, $environmentPrefix, $driver); + $connection = new Connection($pdo); + $fixture = $this->reserveFixtureIdentity($connection); + $tableName = $fixture['table_name']; + + $this->assertDoltWorkingSetCleanBeforeFixture($pdo, $connection); + (new NormativeCatalog($pdo))->createTables(); + CatalogOwnership::markCurrentVersion($pdo); + $pdo->exec( + 'CREATE TABLE ' . $this->qualifiedTable($connection, $tableName) + . ' (' . $connection->profile->quoteIdentifier('__case_ordinal') . ' BIGINT NOT NULL PRIMARY KEY)', + ); + $pdo->prepare( + 'INSERT INTO dataset ' + . '(dataset_id, spec_version, source_format, physical_table_schema, physical_table_name, dataset_name, source_case_count, imported_at) ' + . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + )->execute([$fixture['dataset_id'], '1.0', 'fixture', null, $tableName, 'preexisting deterministic namespace', 0, self::IMPORTED_AT]); + $this->commitDoltFixture($pdo, $connection, $fixture); + + try { + try { + $this->installFixture($pdo, $connection, $fixture); + self::fail('Dirty deterministic fixture namespace was silently deleted.'); + } catch (RuntimeException $exception) { + self::assertStringContainsString('deterministic fixture namespace is not clean', $exception->getMessage()); + } + + self::assertSame( + 'preexisting deterministic namespace', + $this->scalar($pdo, 'SELECT dataset_name FROM dataset WHERE dataset_id = ?', [$fixture['dataset_id']]), + ); + self::assertContains($tableName, $this->tableNames($pdo)); + } finally { + $this->purgeDirtyNamespaceFixture($pdo, $connection, $fixture); + } + } + + public function testDoltDatabaseIsolationSeparatesAdminAndTargetCredentials(): void + { + self::assertNull($this->doltAdminCredentials(false, 'root')); + self::assertNull($this->doltAdminCredentials('root', false)); + self::assertSame( + ['user' => 'root', 'password' => 'root'], + $this->doltAdminCredentials('root', 'root'), + ); + + $firstDatabase = $this->newDoltDatabaseName(); + $secondDatabase = $this->newDoltDatabaseName(); + + self::assertNotSame($firstDatabase, $secondDatabase); + foreach ([$firstDatabase, $secondDatabase] as $database) { + self::assertLessThanOrEqual(64, strlen($database)); + self::assertMatchesRegularExpression('/^[a-z][a-z0-9_]*$/', $database); + } + $configuredDsn = 'mysql:host=127.0.0.1;port=3306;dbname=openstatspec;charset=utf8mb4'; + self::assertSame( + [ + 'admin' => ['dsn' => $configuredDsn, 'user' => 'root', 'password' => 'root'], + 'isolated' => [ + 'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=' . $firstDatabase . ';charset=utf8mb4', + 'user' => 'openstatspec', + 'password' => 'target-secret', + ], + ], + $this->doltConnectionConfiguration( + $configuredDsn, + 'root', + 'root', + 'openstatspec', + 'target-secret', + $firstDatabase, + ), + ); + self::assertSame( + 'mysql:unix_socket=/tmp/dolt.sock;charset=utf8mb4;dbname=' . $secondDatabase, + $this->dsnForDoltDatabase('mysql:unix_socket=/tmp/dolt.sock;charset=utf8mb4', $secondDatabase), + ); + } + + public function testDoltRepositoryEvidenceNormalizesDriverValuesForStableComparison(): void + { + self::assertSame( + [ + 'branch' => 'main', + 'head' => 'abc123', + 'history' => ['abc123', 'parent456'], + 'status' => [ + ['table_name' => 'respondents', 'staged' => false, 'status' => 'modified'], + ['table_name' => 'variable', 'staged' => true, 'status' => 'modified'], + ], + 'working_diff' => [ + ['table_name' => 'respondents', 'data_change' => true, 'schema_change' => false], + ['table_name' => 'variable', 'data_change' => true, 'schema_change' => false], + ], + ], + $this->normalizeDoltRepositoryEvidence( + ['branch_name' => 'main', 'head_hash' => 'abc123'], + [['commit_hash' => 'abc123'], ['commit_hash' => 'parent456']], + [ + ['table_name' => 'respondents', 'staged' => '0', 'status' => 'modified'], + ['table_name' => 'variable', 'staged' => 1, 'status' => 'modified'], + ], + [ + ['table_name' => 'respondents', 'data_change' => '1', 'schema_change' => '0'], + ['table_name' => 'variable', 'data_change' => 1, 'schema_change' => 0], + ], + ), + ); + } + + public function testMySqlFamilyMetadataNormalizesDriverKeyCase(): void + { + self::assertSame( + ['column_name' => 'destination', 'column_type' => 'double'], + $this->normalizeMySqlMetadataRow([ + 'COLUMN_NAME' => 'destination', + 'Column_Type' => 'double', + ]), + ); + } + + /** @param list $expectedVersionFamilies */ + private function assertExpectedVersionFamily( + Connection $connection, + array $expectedVersionFamilies, + ?string $expectedVersionEnvironment, + ): void { + $version = $connection->serverVersion; + $claim = ServerVersionPolicy::assess($connection->profileName, $version); + self::assertSame(ServerVersionPolicy::claim($connection->profileName), $claim['matched_claim']); + + if ($connection->profileName === 'sqlite') { + self::assertSame(['>=3.24.0 <4.0.0'], $expectedVersionFamilies); + return; + } + + $activeFamily = $this->versionFamily($connection->profileName, $version); + self::assertContains($activeFamily, $expectedVersionFamilies); + + if ($expectedVersionEnvironment !== null) { + $expectedVersion = getenv($expectedVersionEnvironment); + if (is_string($expectedVersion) && $expectedVersion !== '') { + self::assertSame( + $this->versionFamily($connection->profileName, $expectedVersion), + $activeFamily, + $expectedVersionEnvironment . ' must match by claimed .x family, not patch.', + ); + } + } + } + + private function versionFamily(string $profile, string $version): string + { + $normalized = ServerVersionPolicy::normalize($profile, $version); + self::assertIsString($normalized, $profile . ': normalizable server version'); + self::assertMatchesRegularExpression('/^\d+\.\d+(?:\.\d+)?$/', $normalized); + $parts = explode('.', $normalized); + + return $profile === 'postgresql' + ? $parts[0] . '.x' + : $parts[0] . '.' . $parts[1] . '.x'; + } + + /** + * @return array{ + * dataset: array, + * variables: list>, + * columns: list, + * tables: list, + * table_name: string + * } + */ + /** + * @param array{dataset_id: string, source_variable_id: string, destination_variable_id: string, table_name: string, dataset_name: string}|null $fixture + * @return array{dataset: array, variables: list>, columns: list, tables: list, dataset_id: string, source_variable_id: string, destination_variable_id: string, table_name: string, dataset_name: string} + */ + private function installFixture(PDO $pdo, Connection $connection, ?array $fixture = null): array + { + $this->assertDoltWorkingSetCleanBeforeFixture($pdo, $connection); + (new NormativeCatalog($pdo))->createTables(); + CatalogOwnership::markCurrentVersion($pdo); + + $fixture ??= $this->reserveFixtureIdentity($connection); + $this->assertFixtureNamespaceClean($pdo, $connection, $fixture); + $this->activeFixture = $fixture; + + $tableName = $fixture['table_name']; + + $quotedTable = $this->qualifiedTable($connection, $tableName); + $quotedOrdinal = $connection->profile->quoteIdentifier('__case_ordinal'); + $quotedSource = $connection->profile->quoteIdentifier('source_value'); + $quotedDestination = $connection->profile->quoteIdentifier('destination'); + + $pdo->exec( + 'CREATE TABLE ' . $quotedTable . ' (' + . $quotedOrdinal . ' BIGINT NOT NULL PRIMARY KEY, ' + . $quotedSource . ' ' . $connection->profile->numericType() . ' NULL, ' + . $quotedDestination . ' ' . $connection->profile->numericType() . ' NULL' + . ')', + ); + + $pdo->prepare( + 'INSERT INTO dataset ' + . '(dataset_id, spec_version, source_format, physical_table_schema, physical_table_name, dataset_name, source_case_count, imported_at) ' + . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + )->execute([ + $fixture['dataset_id'], + '1.0', + 'fixture', + null, + $tableName, + $fixture['dataset_name'], + 5, + self::IMPORTED_AT, + ]); + + $insertVariable = $pdo->prepare( + 'INSERT INTO variable ' + . '(variable_id, dataset_id, source_ordinal, source_name, physical_name, storage_kind) ' + . 'VALUES (?, ?, ?, ?, ?, ?)', + ); + $insertVariable->execute([ + $fixture['source_variable_id'], + $fixture['dataset_id'], + 1, + 'SourceValue', + 'source_value', + 'numeric', + ]); + $insertVariable->execute([ + $fixture['destination_variable_id'], + $fixture['dataset_id'], + 2, + 'Destination', + 'destination', + 'numeric', + ]); + + $insertCase = $pdo->prepare( + 'INSERT INTO ' . $quotedTable . ' (' . $quotedOrdinal . ', ' . $quotedSource . ', ' . $quotedDestination . ') VALUES (?, ?, ?)', + ); + foreach ([[1, 1.0, -1.0], [2, 2.0, -1.0], [3, 3.0, -1.0], [4, 9.0, -1.0], [5, null, -1.0]] as $row) { + $insertCase->execute($row); + } + + $this->commitDoltFixture($pdo, $connection, $fixture); + + return [ + 'dataset' => $this->datasetRow($pdo), + 'variables' => $this->variableIdentityRows($pdo), + 'columns' => $this->tableColumns($pdo, $connection, $tableName), + 'tables' => $this->tableNames($pdo), + ...$fixture, + ]; + } + + /** @param array{dataset_id: string, source_variable_id: string, destination_variable_id: string, table_name: string, dataset_name: string} $fixture */ + private function purgeFixture(PDO $pdo, Connection $connection, array $fixture): void + { + if ($connection->profileName === 'dolt') { + return; + } + + (new NormativeCatalog($pdo))->createTables(); + + $dataset = $this->rows( + $pdo, + 'SELECT physical_table_name, dataset_name, source_format, source_case_count FROM dataset WHERE dataset_id = ?', + [$fixture['dataset_id']], + ); + if ($dataset !== []) { + self::assertCount(1, $dataset); + self::assertSame([ + 'physical_table_name' => $fixture['table_name'], + 'dataset_name' => $fixture['dataset_name'], + 'source_format' => 'fixture', + 'source_case_count' => 5, + ], [ + 'physical_table_name' => (string) $dataset[0]['physical_table_name'], + 'dataset_name' => (string) $dataset[0]['dataset_name'], + 'source_format' => (string) $dataset[0]['source_format'], + 'source_case_count' => (int) $dataset[0]['source_case_count'], + ]); + } + + $pdo->prepare( + 'DELETE FROM variable_value_label_set WHERE variable_id IN (SELECT variable_id FROM variable WHERE dataset_id = ?)', + )->execute([$fixture['dataset_id']]); + $pdo->prepare( + 'DELETE FROM value_label WHERE value_label_set_id IN (SELECT value_label_set_id FROM value_label_set WHERE dataset_id = ?)', + )->execute([$fixture['dataset_id']]); + $pdo->prepare('DELETE FROM value_label_set WHERE dataset_id = ?')->execute([$fixture['dataset_id']]); + $pdo->prepare('DELETE FROM variable WHERE dataset_id = ?')->execute([$fixture['dataset_id']]); + $pdo->prepare('DELETE FROM dataset WHERE dataset_id = ?')->execute([$fixture['dataset_id']]); + $pdo->exec('DROP TABLE IF EXISTS ' . $this->qualifiedTable($connection, $fixture['table_name'])); + } + + /** @param array{dataset_id: string, source_variable_id: string, destination_variable_id: string, table_name: string, dataset_name: string} $fixture */ + private function assertFixtureNamespaceClean(PDO $pdo, Connection $connection, array $fixture): void + { + $existingRows = (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ? OR physical_table_name = ?', [$fixture['dataset_id'], $fixture['table_name']]); + $existingVariables = (int) $this->scalar( + $pdo, + 'SELECT COUNT(*) FROM variable WHERE variable_id IN (?, ?) OR dataset_id = ?', + [$fixture['source_variable_id'], $fixture['destination_variable_id'], $fixture['dataset_id']], + ); + if ($existingRows !== 0 || $existingVariables !== 0 || in_array($fixture['table_name'], $this->tableNames($pdo), true)) { + throw new RuntimeException('The deterministic fixture namespace is not clean; refusing to delete pre-existing state.'); + } + } + + /** @param array{dataset_id: string, source_variable_id: string, destination_variable_id: string, table_name: string, dataset_name: string} $fixture */ + private function purgeDirtyNamespaceFixture(PDO $pdo, Connection $connection, array $fixture): void + { + if ($connection->profileName === 'dolt') { + return; + } + + (new NormativeCatalog($pdo))->createTables(); + $datasetName = $this->scalar($pdo, 'SELECT dataset_name FROM dataset WHERE dataset_id = ?', [$fixture['dataset_id']]); + self::assertSame('preexisting deterministic namespace', $datasetName); + $pdo->prepare('DELETE FROM dataset WHERE dataset_id = ?')->execute([$fixture['dataset_id']]); + $pdo->exec('DROP TABLE IF EXISTS ' . $this->qualifiedTable($connection, $fixture['table_name'])); + } + + private function existingTargetPlan(): TransformationPlan + { + return new TransformationPlan($this->activeFixtureId(), [ + new RecodeOperation('SourceValue', 'Destination', [ + new RecodeRule( + new ExactValueSelector(ScalarValue::number(1)), + new AssignValueAction(ScalarValue::number(10)), + ), + new RecodeRule( + new NumericRangeSelector(2.0, 3.0), + new AssignValueAction(ScalarValue::number(20)), + ), + new RecodeRule( + new MissingValueSelector(), + new AssignValueAction(ScalarValue::number(99)), + ), + new RecodeRule(new ElseSelector(), new CopySourceAction()), + ]), + new SetVariableLabelOperation('Destination', 'Recoded destination'), + new SetValueLabelsOperation('Destination', [ + new ValueLabel(ScalarValue::number(10), 'Ten'), + new ValueLabel(ScalarValue::number(20), 'Twenty'), + new ValueLabel(ScalarValue::number(99), 'Missing source'), + ]), + ]); + } + + private function createTargetPlan(): TransformationPlan + { + return new TransformationPlan($this->activeFixtureId(), [ + new RecodeOperation('SourceValue', 'CreatedTarget', [ + new RecodeRule( + new ExactValueSelector(ScalarValue::number(1)), + new AssignValueAction(ScalarValue::number(10)), + ), + new RecodeRule( + new NumericRangeSelector(2.0, 3.0), + new AssignValueAction(ScalarValue::number(20)), + ), + new RecodeRule( + new MissingValueSelector(), + new AssignValueAction(ScalarValue::number(99)), + ), + new RecodeRule(new ElseSelector(), new CopySourceAction()), + ]), + new SetVariableLabelOperation('CreatedTarget', 'Recoded created target'), + new SetValueLabelsOperation('CreatedTarget', [ + new ValueLabel(ScalarValue::number(10), 'Ten'), + new ValueLabel(ScalarValue::number(20), 'Twenty'), + new ValueLabel(ScalarValue::number(99), 'Missing source'), + ]), + ]); + } + + private function servicePdo(string $expectedProfile, ?string $environmentPrefix, string $driver): PDO + { + if (!in_array($driver, PDO::getAvailableDrivers(), true)) { + self::markTestSkipped('PDO ' . $driver . ' is not available in this PHP environment.'); + } + + if ($expectedProfile === 'sqlite') { + $pdo = new PDO('sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_STRINGIFY_FETCHES => false, + ]); + $pdo->exec('PRAGMA foreign_keys = ON'); + + return $pdo; + } + + if ($environmentPrefix === null) { + throw new RuntimeException('A non-SQLite integration profile requires an environment prefix.'); + } + + $dsn = getenv($environmentPrefix . '_DSN'); + if (!is_string($dsn) || $dsn === '') { + self::markTestSkipped($environmentPrefix . '_DSN is not configured.'); + } + + $user = getenv($environmentPrefix . '_USER'); + $password = getenv($environmentPrefix . '_PASSWORD'); + + $options = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_STRINGIFY_FETCHES => false, + ]; + + if ($expectedProfile === 'dolt') { + $adminCredentials = $this->doltAdminCredentials( + getenv($environmentPrefix . '_ADMIN_USER'), + getenv($environmentPrefix . '_ADMIN_PASSWORD'), + ); + if ($adminCredentials === null) { + self::markTestSkipped( + $environmentPrefix . '_ADMIN_USER and ' . $environmentPrefix + . '_ADMIN_PASSWORD must be explicitly configured for isolated Dolt test databases.', + ); + } + + return $this->isolatedDoltPdo( + $dsn, + $adminCredentials['user'], + $adminCredentials['password'], + is_string($user) ? $user : null, + is_string($password) ? $password : null, + $options, + ); + } + + return new PDO( + $dsn, + is_string($user) ? $user : null, + is_string($password) ? $password : null, + $options, + ); + } + + /** @return array{user: string, password: string}|null */ + private function doltAdminCredentials(string|false $user, string|false $password): ?array + { + if (!is_string($user) || $user === '' || !is_string($password)) { + return null; + } + + return ['user' => $user, 'password' => $password]; + } + + /** + * @return array{ + * admin: array{dsn: string, user: string, password: string}, + * isolated: array{dsn: string, user: string|null, password: string|null} + * } + */ + private function doltConnectionConfiguration( + string $dsn, + string $adminUser, + string $adminPassword, + ?string $targetUser, + ?string $targetPassword, + string $database, + ): array { + return [ + 'admin' => ['dsn' => $dsn, 'user' => $adminUser, 'password' => $adminPassword], + 'isolated' => [ + 'dsn' => $this->dsnForDoltDatabase($dsn, $database), + 'user' => $targetUser, + 'password' => $targetPassword, + ], + ]; + } + + /** @param array $options */ + private function isolatedDoltPdo( + string $dsn, + string $adminUser, + string $adminPassword, + ?string $targetUser, + ?string $targetPassword, + array $options, + ): PDO { + $database = $this->newDoltDatabaseName(); + $configuration = $this->doltConnectionConfiguration( + $dsn, + $adminUser, + $adminPassword, + $targetUser, + $targetPassword, + $database, + ); + $admin = new PDO( + $configuration['admin']['dsn'], + $configuration['admin']['user'], + $configuration['admin']['password'], + $options, + ); + $admin->exec('CREATE DATABASE ' . $this->quoteDoltDatabaseName($database)); + $this->doltTestDatabases[] = ['admin' => $admin, 'database' => $database]; + if ($targetUser !== null && $targetUser !== '') { + $quotedTargetUser = $admin->quote($targetUser); + if (!is_string($quotedTargetUser)) { + throw new RuntimeException('Unable to quote the Dolt target user.'); + } + $admin->exec( + 'GRANT ALL PRIVILEGES ON ' . $this->quoteDoltDatabaseName($database) + . ".* TO {$quotedTargetUser}@'%'", + ); + } + + $pdo = new PDO( + $configuration['isolated']['dsn'], + $configuration['isolated']['user'], + $configuration['isolated']['password'], + $options, + ); + if ((new Connection($pdo))->profileName !== 'dolt') { + throw new RuntimeException('The configured Dolt DSN does not identify a Dolt server.'); + } + self::assertSame($database, $this->scalar($pdo, 'SELECT DATABASE()', [])); + + return $pdo; + } + + private function newDoltDatabaseName(): string + { + $this->doltDatabaseRunId ??= bin2hex(random_bytes(8)); + + return sprintf( + 'openstatspec_it_%d_%s_%d', + getmypid(), + $this->doltDatabaseRunId, + ++$this->doltDatabaseSequence, + ); + } + + private function dsnForDoltDatabase(string $dsn, string $database): string + { + $this->quoteDoltDatabaseName($database); + if (!str_starts_with(strtolower($dsn), 'mysql:')) { + throw new RuntimeException('Dolt integration requires a mysql PDO DSN.'); + } + + $parts = explode(';', substr($dsn, strlen('mysql:'))); + $databasePart = 'dbname=' . $database; + $databasePartIndex = null; + foreach ($parts as $index => $part) { + $key = strtolower(trim((string) explode('=', $part, 2)[0])); + if ($key !== 'dbname') { + continue; + } + if ($databasePartIndex !== null) { + throw new RuntimeException('Dolt integration DSN contains more than one dbname setting.'); + } + $databasePartIndex = $index; + } + + if ($databasePartIndex === null) { + if (end($parts) === '') { + array_pop($parts); + } + $parts[] = $databasePart; + } else { + $parts[$databasePartIndex] = $databasePart; + } + + return 'mysql:' . implode(';', $parts); + } + + private function quoteDoltDatabaseName(string $database): string + { + if (strlen($database) > 64 || preg_match('/^[a-z][a-z0-9_]*$/', $database) !== 1) { + throw new RuntimeException('Dolt test database name is not a safe SQL identifier.'); + } + + return '`' . $database . '`'; + } + + /** @return array */ + private function datasetRow(PDO $pdo): array + { + $rows = $this->rows( + $pdo, + 'SELECT * FROM dataset WHERE dataset_id = ?', + [$this->activeFixtureId()], + ); + self::assertCount(1, $rows); + + return $rows[0]; + } + + /** @return list> */ + private function variableIdentityRows(PDO $pdo): array + { + return $this->rows( + $pdo, + 'SELECT variable_id, source_ordinal, source_name, physical_name, storage_kind, declared_string_width ' + . 'FROM variable WHERE dataset_id = ? ORDER BY source_ordinal', + [$this->activeFixtureId()], + ); + } + + /** @return list */ + private function destinationValueLabels(PDO $pdo): array + { + return $this->valueLabelsForVariable($pdo, 'Destination'); + } + + /** @return list */ + private function valueLabelsForVariable(PDO $pdo, string $sourceName): array + { + $rows = $this->rows( + $pdo, + 'SELECT label.ordinal, label.code_kind, label.numeric_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 = ? AND variable.source_name = ? ' + . 'ORDER BY label.ordinal', + [$this->activeFixtureId(), $sourceName], + ); + + return array_map( + static function (array $row): array { + return [ + 'ordinal' => (int) $row['ordinal'], + 'code_kind' => (string) $row['code_kind'], + 'value' => (float) $row['numeric_code'], + 'label' => (string) $row['label'], + ]; + }, + $rows, + ); + } + + /** @return list> */ + private function valueLabelSetRows(PDO $pdo): array + { + return $this->rows( + $pdo, + 'SELECT value_label_set_id, dataset_id, name FROM value_label_set WHERE dataset_id = ? ORDER BY value_label_set_id', + [$this->activeFixtureId()], + ); + } + + /** @return list> */ + private function valueLabelRows(PDO $pdo): array + { + return $this->rows( + $pdo, + 'SELECT value_label_id, value_label_set_id, ordinal, code_kind, numeric_code, string_code, label ' + . 'FROM value_label WHERE value_label_set_id IN (SELECT value_label_set_id FROM value_label_set WHERE dataset_id = ?) ' + . 'ORDER BY value_label_set_id, ordinal, value_label_id', + [$this->activeFixtureId()], + ); + } + + /** @return list> */ + private function variableValueLabelSetRows(PDO $pdo): array + { + return $this->rows( + $pdo, + 'SELECT variable_id, value_label_set_id FROM variable_value_label_set ' + . 'WHERE variable_id IN (SELECT variable_id FROM variable WHERE dataset_id = ?) ' + . 'ORDER BY variable_id, value_label_set_id', + [$this->activeFixtureId()], + ); + } + + /** @return array{dataset: list>, variables: list>, value_label_sets: list>, value_labels: list>, variable_value_label_sets: list>} */ + private function fullCatalogSnapshot(PDO $pdo): array + { + return [ + 'dataset' => $this->rows( + $pdo, + 'SELECT * FROM dataset WHERE dataset_id = ? ORDER BY dataset_id', + [$this->activeFixtureId()], + ), + 'variables' => $this->rows( + $pdo, + 'SELECT * FROM variable WHERE dataset_id = ? ORDER BY source_ordinal, variable_id', + [$this->activeFixtureId()], + ), + 'value_label_sets' => $this->valueLabelSetRows($pdo), + 'value_labels' => $this->valueLabelRows($pdo), + 'variable_value_label_sets' => $this->variableValueLabelSetRows($pdo), + ]; + } + + /** @return list */ + private function numericColumn( + PDO $pdo, + Connection $connection, + string $tableName, + string $column, + ): array { + $statement = $pdo->query( + 'SELECT ' . $connection->profile->quoteIdentifier($column) + . ' FROM ' . $this->qualifiedTable($connection, $tableName) + . ' ORDER BY ' . $connection->profile->quoteIdentifier('__case_ordinal'), + ); + self::assertInstanceOf(PDOStatement::class, $statement); + + return array_values(array_map( + static fn(mixed $value): ?float => $value === null ? null : (float) $value, + $statement->fetchAll(PDO::FETCH_COLUMN), + )); + } + + /** + * @param list $columns + * @return list> + */ + private function tableRows(PDO $pdo, Connection $connection, string $tableName, array $columns): array + { + $statement = $pdo->query( + 'SELECT ' . implode(', ', array_map($connection->profile->quoteIdentifier(...), $columns)) + . ' FROM ' . $this->qualifiedTable($connection, $tableName) + . ' ORDER BY ' . $connection->profile->quoteIdentifier('__case_ordinal'), + ); + self::assertInstanceOf(PDOStatement::class, $statement); + + return array_values(array_map( + static function (array $row) use ($columns): array { + $normalized = []; + foreach ($columns as $column) { + $value = $row[$column] ?? null; + $normalized[$column] = $column === '__case_ordinal' + ? (int) $value + : ($value === null ? null : (float) $value); + } + + return $normalized; + }, + $statement->fetchAll(PDO::FETCH_ASSOC), + )); + } + + /** + * Preserves the values and PHP types returned by the configured PDO driver. + * + * @param list $columns + * @return list> + */ + private function rawTableRows(PDO $pdo, Connection $connection, string $tableName, array $columns): array + { + $statement = $pdo->query( + 'SELECT ' . implode(', ', array_map($connection->profile->quoteIdentifier(...), $columns)) + . ' FROM ' . $this->qualifiedTable($connection, $tableName) + . ' ORDER BY ' . $connection->profile->quoteIdentifier('__case_ordinal'), + ); + self::assertInstanceOf(PDOStatement::class, $statement); + + return array_values($statement->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return list */ + private function tableNames(PDO $pdo): array + { + $sql = match ((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME)) { + 'sqlite' => "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + 'pgsql' => "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = current_schema() ORDER BY tablename", + 'mysql' => 'SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name', + default => throw new RuntimeException('Unsupported integration driver.'), + }; + + $statement = $pdo->query($sql); + self::assertInstanceOf(PDOStatement::class, $statement); + + return array_values(array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN))); + } + + /** @return list */ + private function tableColumns(PDO $pdo, Connection $connection, string $tableName): array + { + $driver = (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $rows = match ($driver) { + 'sqlite' => $this->query($pdo, 'PRAGMA table_info(' . $connection->profile->quoteIdentifier($tableName) . ')')->fetchAll(PDO::FETCH_ASSOC), + 'pgsql' => $this->rows( + $pdo, + 'SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position', + [$tableName], + ), + 'mysql' => $this->rows( + $pdo, + 'SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? ORDER BY ordinal_position', + [$tableName], + ), + default => throw new RuntimeException('Unsupported integration driver.'), + }; + + if ($driver === 'sqlite') { + return array_values(array_map( + static fn(array $column): string => (string) $column['name'], + $rows, + )); + } + + if ($driver === 'mysql') { + $rows = array_map($this->normalizeMySqlMetadataRow(...), $rows); + } + + return array_values(array_map( + static fn(array $column): string => (string) $column['column_name'], + $rows, + )); + } + + private function assertProfileNumericColumnType(PDO $pdo, Connection $connection, string $tableName, string $column): void + { + $driver = (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $rows = match ($driver) { + 'sqlite' => $this->query($pdo, 'PRAGMA table_info(' . $connection->profile->quoteIdentifier($tableName) . ')')->fetchAll(PDO::FETCH_ASSOC), + 'pgsql' => $this->rows( + $pdo, + 'SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position', + [$tableName], + ), + 'mysql' => $this->rows( + $pdo, + 'SELECT column_name, column_type FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? ORDER BY ordinal_position', + [$tableName], + ), + default => throw new RuntimeException('Unsupported integration driver.'), + }; + + if ($driver === 'mysql') { + $rows = array_map($this->normalizeMySqlMetadataRow(...), $rows); + } + + foreach ($rows as $row) { + $name = $driver === 'sqlite' ? (string) $row['name'] : (string) $row['column_name']; + if ($name !== $column) { + continue; + } + + $actual = $driver === 'sqlite' + ? (string) $row['type'] + : (string) ($row['data_type'] ?? $row['column_type']); + self::assertSame($this->normalizedSqlType($connection->profile->numericType()), $this->normalizedSqlType($actual)); + + return; + } + + self::fail(sprintf('Expected physical column "%s" to exist.', $column)); + } + + private function normalizedSqlType(string $type): string + { + return strtoupper(preg_replace('/\\s+/', ' ', trim($type)) ?? ''); + } + + /** + * @param array $row + * @return array + */ + private function normalizeMySqlMetadataRow(array $row): array + { + return array_change_key_case($row, CASE_LOWER); + } + + /** + * @return array{ + * branch: string, + * head: string, + * history: list, + * status: list, + * working_diff: list + * }|null + */ + private function doltRepositoryEvidence(PDO $pdo, Connection $connection): ?array + { + if ($connection->profileName !== 'dolt') { + return null; + } + + $identity = $this->query( + $pdo, + "SELECT active_branch() AS branch_name, dolt_hashof('HEAD') AS head_hash", + )->fetch(PDO::FETCH_ASSOC); + if (!is_array($identity)) { + self::fail('Dolt repository identity could not be read.'); + } + + return $this->normalizeDoltRepositoryEvidence( + $identity, + $this->rows( + $pdo, + 'SELECT commit_hash FROM dolt_log ORDER BY commit_order DESC, commit_hash', + [], + ), + $this->rows( + $pdo, + 'SELECT table_name, staged, status FROM dolt_status ORDER BY table_name, staged, status', + [], + ), + $this->rows( + $pdo, + "SELECT table_name, data_change, schema_change FROM dolt_diff WHERE commit_hash = 'WORKING' ORDER BY table_name", + [], + ), + ); + } + + /** + * @param array $identity + * @param list> $history + * @param list> $status + * @param list> $workingDiff + * @return array{ + * branch: string, + * head: string, + * history: list, + * status: list, + * working_diff: list + * } + */ + private function normalizeDoltRepositoryEvidence( + array $identity, + array $history, + array $status, + array $workingDiff, + ): array { + return [ + 'branch' => (string) ($identity['branch_name'] ?? ''), + 'head' => (string) ($identity['head_hash'] ?? ''), + 'history' => array_map( + static fn(array $row): string => (string) ($row['commit_hash'] ?? ''), + $history, + ), + 'status' => array_map( + static fn(array $row): array => [ + 'table_name' => (string) ($row['table_name'] ?? ''), + 'staged' => (bool) ($row['staged'] ?? false), + 'status' => (string) ($row['status'] ?? ''), + ], + $status, + ), + 'working_diff' => array_map( + static fn(array $row): array => [ + 'table_name' => (string) ($row['table_name'] ?? ''), + 'data_change' => (bool) ($row['data_change'] ?? false), + 'schema_change' => (bool) ($row['schema_change'] ?? false), + ], + $workingDiff, + ), + ]; + } + + /** @param list $tables */ + private function assertNoArtifactTables(array $tables): void + { + $artifactTables = array_values(array_filter( + $tables, + static fn(string $table): bool => preg_match('/snapshot|staging|copied|derived|rollback|parallel|history/i', $table) === 1, + )); + + self::assertSame([], $artifactTables); + } + + /** @return array{dataset_id: string, source_variable_id: string, destination_variable_id: string, table_name: string, dataset_name: string} */ + private function reserveFixtureIdentity(Connection $connection): array + { + $sequence = ++$this->fixtureSequence; + if ($this->name() === 'testExistingTargetTransformationPreservesPhysicalIdentityInPlace' && $sequence === 1) { + return [ + 'dataset_id' => self::DATASET_ID, + 'source_variable_id' => self::SOURCE_VARIABLE_ID, + 'destination_variable_id' => self::DESTINATION_VARIABLE_ID, + 'table_name' => 'inplace_existing_target_' . $connection->profileName, + 'dataset_name' => 'in-place existing-target ' . $connection->profileName, + ]; + } + + $scope = $this->name() . ':' . $this->dataName() . ':' . $connection->profileName . ':' . $sequence; + $suffix = substr(hash('sha256', $scope), 0, 12); + + return [ + 'dataset_id' => $this->fixtureUuid($scope . ':dataset'), + 'source_variable_id' => $this->fixtureUuid($scope . ':source'), + 'destination_variable_id' => $this->fixtureUuid($scope . ':destination'), + 'table_name' => 'inplace_' . $connection->profileName . '_' . $suffix, + 'dataset_name' => 'in-place ' . $connection->profileName . ' ' . $suffix, + ]; + } + + private function fixtureUuid(string $scope): string + { + $hash = hash('sha256', $scope); + + return substr($hash, 0, 8) . '-' + . substr($hash, 8, 4) . '-5' + . substr($hash, 12, 3) . '-a' + . substr($hash, 15, 3) . '-' + . substr($hash, 18, 12); + } + + /** @return array{dataset_id: string, source_variable_id: string, destination_variable_id: string, table_name: string, dataset_name: string} */ + private function activeFixture(): array + { + if ($this->activeFixture === null) { + throw new RuntimeException('A fixture must be installed before querying its catalog state.'); + } + + return $this->activeFixture; + } + + private function activeFixtureId(): string + { + return $this->activeFixture()['dataset_id']; + } + + /** @param array{dataset_id: string, source_variable_id: string, destination_variable_id: string, table_name: string, dataset_name: string} $fixture */ + private function commitDoltFixture(PDO $pdo, Connection $connection, array $fixture): void + { + if ($connection->profileName !== 'dolt') { + return; + } + + $statement = $pdo->prepare('CALL DOLT_COMMIT(?, ?)'); + $statement->execute(['-Am', 'OpenStatSpec in-place fixture ' . $fixture['dataset_id']]); + } + + private function assertDoltWorkingSetCleanBeforeFixture(PDO $pdo, Connection $connection): void + { + if ($connection->profileName !== 'dolt') { + return; + } + + $statement = $pdo->query('SELECT table_name FROM dolt_status ORDER BY table_name'); + self::assertInstanceOf(PDOStatement::class, $statement); + $dirtyTables = array_values(array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN))); + if ($dirtyTables !== []) { + throw new RuntimeException( + 'Dolt fixture setup requires a clean working set before its baseline commit; dirty tables: ' + . implode(', ', $dirtyTables) . '.', + ); + } + } + + private function qualifiedTable(Connection $connection, string $tableName): string + { + return $connection->profile->quoteIdentifier($tableName); + } + + private function query(PDO $pdo, string $sql): PDOStatement + { + $statement = $pdo->query($sql); + self::assertInstanceOf(PDOStatement::class, $statement); + + return $statement; + } + + /** @param list $parameters */ + private function scalar(PDO $pdo, string $sql, array $parameters): mixed + { + $statement = $pdo->prepare($sql); + $statement->execute($parameters); + + return $statement->fetchColumn(); + } + + /** + * @param list $parameters + * @return list> + */ + private function rows(PDO $pdo, string $sql, array $parameters): array + { + $statement = $pdo->prepare($sql); + $statement->execute($parameters); + + return array_values($statement->fetchAll(PDO::FETCH_ASSOC)); + } +}