From d83c20e6a00ea280c498cfd4767981144bfd6ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 11:19:54 +0300 Subject: [PATCH 01/16] docs: plan PHP in-place service matrix --- .../2026-08-12-php-inplace-service-matrix.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-php-inplace-service-matrix.md 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. From ba7f4c899a81081d1e43761c8a31d8ffd3f680f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 11:33:12 +0300 Subject: [PATCH 02/16] test: add PHP in-place service evidence --- .../InPlaceTransformationServiceTest.php | 514 ++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 tests/Integration/InPlaceTransformationServiceTest.php diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php new file mode 100644 index 0000000..5e2e932 --- /dev/null +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -0,0 +1,514 @@ +, 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']; + } + + /** @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); + } + + /** @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 { + $plan = $this->existingTargetPlan(); + $result = (new InPlaceTransformationExecutor($connection))->execute($plan); + + self::assertSame(self::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' => self::DESTINATION_VARIABLE_ID, + 'variable_label' => 'Recoded destination', + ], + ], + $this->rows( + $pdo, + 'SELECT variable_id, variable_label FROM variable WHERE dataset_id = ? AND source_name = ?', + [self::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(1, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ?', [self::DATASET_ID])); + self::assertSame(2, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM variable WHERE dataset_id = ?', [self::DATASET_ID])); + $this->assertNoArtifactTables($fixture['tables']); + } finally { + $this->purgeFixture($pdo, $connection); + } + } + + /** @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 + * } + */ + private function installFixture(PDO $pdo, Connection $connection): array + { + $this->purgeFixture($pdo, $connection); + + (new NormativeCatalog($pdo))->createTables(); + CatalogOwnership::markCurrentVersion($pdo); + + $tableName = $this->tableName($connection); + $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([ + self::DATASET_ID, + '1.0', + 'fixture', + null, + $tableName, + 'in-place existing-target ' . $connection->profileName, + 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([ + self::SOURCE_VARIABLE_ID, + self::DATASET_ID, + 1, + 'SourceValue', + 'source_value', + 'numeric', + ]); + $insertVariable->execute([ + self::DESTINATION_VARIABLE_ID, + self::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); + } + + return [ + 'dataset' => $this->datasetRow($pdo), + 'variables' => $this->variableIdentityRows($pdo), + 'columns' => $this->tableColumns($pdo, $connection, $tableName), + 'tables' => $this->tableNames($pdo), + 'table_name' => $tableName, + ]; + } + + private function purgeFixture(PDO $pdo, Connection $connection): void + { + (new NormativeCatalog($pdo))->createTables(); + + $datasetTable = $this->scalar( + $pdo, + 'SELECT physical_table_name FROM dataset WHERE dataset_id = ?', + [self::DATASET_ID], + ); + if (is_string($datasetTable) && $datasetTable !== '') { + $pdo->exec('DROP TABLE IF EXISTS ' . $this->qualifiedTable($connection, $datasetTable)); + } + $pdo->exec('DROP TABLE IF EXISTS ' . $this->qualifiedTable($connection, $this->tableName($connection))); + + $pdo->prepare( + 'DELETE FROM variable_value_label_set WHERE variable_id IN (SELECT variable_id FROM variable WHERE dataset_id = ?)', + )->execute([self::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([self::DATASET_ID]); + $pdo->prepare('DELETE FROM value_label_set WHERE dataset_id = ?')->execute([self::DATASET_ID]); + $pdo->prepare('DELETE FROM variable WHERE dataset_id = ?')->execute([self::DATASET_ID]); + $pdo->prepare('DELETE FROM dataset WHERE dataset_id = ?')->execute([self::DATASET_ID]); + } + + private function existingTargetPlan(): TransformationPlan + { + return new TransformationPlan(self::DATASET_ID, [ + 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 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'); + + return new PDO( + $dsn, + is_string($user) ? $user : null, + is_string($password) ? $password : null, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_STRINGIFY_FETCHES => false, + ], + ); + } + + /** @return array */ + private function datasetRow(PDO $pdo): array + { + $rows = $this->rows( + $pdo, + 'SELECT dataset_id, spec_version, source_format, physical_table_schema, physical_table_name, dataset_name, source_case_count, imported_at ' + . 'FROM dataset WHERE dataset_id = ?', + [self::DATASET_ID], + ); + 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', + [self::DATASET_ID], + ); + } + + /** @return list */ + private function destinationValueLabels(PDO $pdo): 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', + [self::DATASET_ID, 'Destination'], + ); + + 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 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), + )); + } + + /** @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, + )); + } + + return array_values(array_map( + static fn(array $column): string => (string) $column['column_name'], + $rows, + )); + } + + /** @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); + } + + private function tableName(Connection $connection): string + { + return 'inplace_existing_target_' . $connection->profileName; + } + + 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)); + } +} From 5f79d9bb763380ca2216f8a0fc0119a49d58ba55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 11:39:30 +0300 Subject: [PATCH 03/16] test: protect in-place fixture namespace --- .../InPlaceTransformationServiceTest.php | 98 +++++++++++++++++-- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index 5e2e932..9c83436 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -126,6 +126,50 @@ public function testExistingTargetTransformationPreservesPhysicalIdentityInPlace } } + /** @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); + $tableName = $this->tableName($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([self::DATASET_ID, '1.0', 'fixture', null, $tableName, 'preexisting deterministic namespace', 0, self::IMPORTED_AT]); + + try { + try { + $this->installFixture($pdo, $connection); + 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 = ?', [self::DATASET_ID]), + ); + self::assertContains($tableName, $this->tableNames($pdo)); + } finally { + $this->purgeDirtyNamespaceFixture($pdo, $connection); + } + } + /** @param list $expectedVersionFamilies */ private function assertExpectedVersionFamily( Connection $connection, @@ -179,12 +223,12 @@ private function versionFamily(string $profile, string $version): string */ private function installFixture(PDO $pdo, Connection $connection): array { - $this->purgeFixture($pdo, $connection); - (new NormativeCatalog($pdo))->createTables(); CatalogOwnership::markCurrentVersion($pdo); $tableName = $this->tableName($connection); + $this->assertFixtureNamespaceClean($pdo, $connection, $tableName); + $quotedTable = $this->qualifiedTable($connection, $tableName); $quotedOrdinal = $connection->profile->quoteIdentifier('__case_ordinal'); $quotedSource = $connection->profile->quoteIdentifier('source_value'); @@ -208,7 +252,7 @@ private function installFixture(PDO $pdo, Connection $connection): array 'fixture', null, $tableName, - 'in-place existing-target ' . $connection->profileName, + $this->fixtureDatasetName($connection), 5, self::IMPORTED_AT, ]); @@ -255,15 +299,25 @@ private function purgeFixture(PDO $pdo, Connection $connection): void { (new NormativeCatalog($pdo))->createTables(); - $datasetTable = $this->scalar( + $dataset = $this->rows( $pdo, - 'SELECT physical_table_name FROM dataset WHERE dataset_id = ?', + 'SELECT physical_table_name, dataset_name, source_format, source_case_count FROM dataset WHERE dataset_id = ?', [self::DATASET_ID], ); - if (is_string($datasetTable) && $datasetTable !== '') { - $pdo->exec('DROP TABLE IF EXISTS ' . $this->qualifiedTable($connection, $datasetTable)); + if ($dataset !== []) { + self::assertCount(1, $dataset); + self::assertSame([ + 'physical_table_name' => $this->tableName($connection), + 'dataset_name' => $this->fixtureDatasetName($connection), + '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->exec('DROP TABLE IF EXISTS ' . $this->qualifiedTable($connection, $this->tableName($connection))); $pdo->prepare( 'DELETE FROM variable_value_label_set WHERE variable_id IN (SELECT variable_id FROM variable WHERE dataset_id = ?)', @@ -274,6 +328,29 @@ private function purgeFixture(PDO $pdo, Connection $connection): void $pdo->prepare('DELETE FROM value_label_set WHERE dataset_id = ?')->execute([self::DATASET_ID]); $pdo->prepare('DELETE FROM variable WHERE dataset_id = ?')->execute([self::DATASET_ID]); $pdo->prepare('DELETE FROM dataset WHERE dataset_id = ?')->execute([self::DATASET_ID]); + $pdo->exec('DROP TABLE IF EXISTS ' . $this->qualifiedTable($connection, $this->tableName($connection))); + } + + private function assertFixtureNamespaceClean(PDO $pdo, Connection $connection, string $tableName): void + { + $existingRows = (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ? OR physical_table_name = ?', [self::DATASET_ID, $tableName]); + $existingVariables = (int) $this->scalar( + $pdo, + 'SELECT COUNT(*) FROM variable WHERE variable_id IN (?, ?) OR dataset_id = ?', + [self::SOURCE_VARIABLE_ID, self::DESTINATION_VARIABLE_ID, self::DATASET_ID], + ); + if ($existingRows !== 0 || $existingVariables !== 0 || in_array($tableName, $this->tableNames($pdo), true)) { + throw new RuntimeException('The deterministic fixture namespace is not clean; refusing to delete pre-existing state.'); + } + } + + private function purgeDirtyNamespaceFixture(PDO $pdo, Connection $connection): void + { + (new NormativeCatalog($pdo))->createTables(); + $datasetName = $this->scalar($pdo, 'SELECT dataset_name FROM dataset WHERE dataset_id = ?', [self::DATASET_ID]); + self::assertSame('preexisting deterministic namespace', $datasetName); + $pdo->prepare('DELETE FROM dataset WHERE dataset_id = ?')->execute([self::DATASET_ID]); + $pdo->exec('DROP TABLE IF EXISTS ' . $this->qualifiedTable($connection, $this->tableName($connection))); } private function existingTargetPlan(): TransformationPlan @@ -478,6 +555,11 @@ private function tableName(Connection $connection): string return 'inplace_existing_target_' . $connection->profileName; } + private function fixtureDatasetName(Connection $connection): string + { + return 'in-place existing-target ' . $connection->profileName; + } + private function qualifiedTable(Connection $connection, string $tableName): string { return $connection->profile->quoteIdentifier($tableName); From c2b0330b9ad2291b91b6cfb5c9611382ee035512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 11:47:33 +0300 Subject: [PATCH 04/16] test: prove in-place target capability boundary --- .../task-2-report.md | 61 ++++ .../InPlaceTransformationServiceTest.php | 262 +++++++++++++++++- 2 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md diff --git a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md new file mode 100644 index 0000000..8541331 --- /dev/null +++ b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md @@ -0,0 +1,61 @@ +# Task 2 Report + +## Scope + +Implemented Task 2 only in `tests/Integration/InPlaceTransformationServiceTest.php`, extending the existing Task 1 service-matrix fixture coverage without changing CI, docs, or production code. + +## What Changed + +- Added `createTargetCapableServices()` for SQLite and PostgreSQL success evidence. +- Added `createTargetRejectedServices()` for MySQL, MariaDB, and Dolt preflight rejection evidence. +- Added `testCreateTargetTransformationCreatesNumericTargetInPlace()` to prove: + - the dataset row and physical table identity stay unchanged, + - a new numeric target catalog variable is created, + - a new physical column is appended in-place, + - recoded values land in the new column, + - metadata operations against the implicit target succeed. +- Added `testCreateTargetTransformationRejectsNonAtomicProfilesWithoutChangingState()` to prove: + - MySQL-family profiles reject the plan during preflight with `TargetCapabilityExceeded`, + - data rows, dataset row, variable catalog rows, column list, table list, and dataset/variable counts remain unchanged. +- Added `createTargetPlan()` and small helper extensions for reusable value-label and row snapshots. + +## TDD Notes + +- Wrote the new evidence tests first. +- Ran the focused test target immediately after adding them. +- Result: the new tests passed on the currently reachable local backend set, so no production defect was exposed and no source changes were necessary. + +## Verification + +Commands run: + +```bash +vendor/bin/phpunit --filter InPlaceTransformationServiceTest +vendor/bin/phpunit --filter "InPlaceTransformationServiceTest|InPlaceTransformationExecutorTest" +php -l tests/Integration/InPlaceTransformationServiceTest.php +git diff --check +``` + +Observed results: + +- `vendor/bin/phpunit --filter InPlaceTransformationServiceTest` + - `OK, but some tests were skipped!` + - `Tests: 20, Assertions: 57, Skipped: 16.` +- `vendor/bin/phpunit --filter "InPlaceTransformationServiceTest|InPlaceTransformationExecutorTest"` + - `OK, but some tests were skipped!` + - `Tests: 41, Assertions: 196, Skipped: 16.` +- `php -l tests/Integration/InPlaceTransformationServiceTest.php` + - `No syntax errors detected` +- `git diff --check` + - no whitespace or patch-shape errors + +## Self-Review + +- Verified the change stays inside the required integration test class. +- Verified the rejection test asserts the documented preflight exception message from the in-place executor capability guard. +- Verified the success test proves unchanged dataset/table identity while checking the newly created catalog variable, physical column, and recoded values. +- Verified no CI or documentation files were modified. + +## Concerns + +- This shell session has no `OPENSTATSPEC_*` DSN environment variables configured, so the fresh local execution covered SQLite plus the related SQLite executor unit path; PostgreSQL/MySQL/MariaDB/Dolt branches remain encoded in the integration matrix tests for configured environments and CI. diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index 9c83436..685e409 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -4,7 +4,9 @@ namespace OpenStatSpec\Tests\Integration; +use OpenStatSpec\Core\DiagnosticCode; use OpenStatSpec\Core\ServerVersionPolicy; +use OpenStatSpec\Core\UnsupportedOperation; use OpenStatSpec\Sql\CatalogOwnership; use OpenStatSpec\Sql\Connection; use OpenStatSpec\Sql\NormativeCatalog; @@ -45,6 +47,26 @@ public static function services(): iterable 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( @@ -126,6 +148,182 @@ public function testExistingTargetTransformationPreservesPhysicalIdentityInPlace } } + /** @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 { + $plan = $this->createTargetPlan(); + $result = (new InPlaceTransformationExecutor($connection))->execute($plan); + + self::assertSame(self::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']), + ); + 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 = ?', [self::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 = ?', + [self::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(1, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ?', [self::DATASET_ID])); + $this->assertNoArtifactTables($fixture['tables']); + } finally { + $this->purgeFixture($pdo, $connection); + } + } + + /** @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 = [ + 'dataset' => $this->datasetRow($pdo), + 'variables' => $this->variableIdentityRows($pdo), + 'rows' => $this->tableRows( + $pdo, + $connection, + $fixture['table_name'], + ['__case_ordinal', 'source_value', 'destination'], + ), + 'columns' => $this->tableColumns($pdo, $connection, $fixture['table_name']), + 'tables' => $this->tableNames($pdo), + 'dataset_count' => (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset', []), + 'variable_count' => (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM variable', []), + ]; + + 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['dataset'], $this->datasetRow($pdo)); + self::assertSame($before['variables'], $this->variableIdentityRows($pdo)); + self::assertSame( + $before['rows'], + $this->tableRows( + $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['dataset_count'], (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset', [])); + self::assertSame($before['variable_count'], (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM variable', [])); + $this->assertNoArtifactTables($fixture['tables']); + } finally { + $this->purgeFixture($pdo, $connection); + } + } + /** @param list $expectedVersionFamilies */ #[DataProvider('services')] public function testFixtureSetupRejectsDirtyDeterministicNamespaceWithoutDeletingIt( @@ -380,6 +578,33 @@ private function existingTargetPlan(): TransformationPlan ]); } + private function createTargetPlan(): TransformationPlan + { + return new TransformationPlan(self::DATASET_ID, [ + 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)) { @@ -446,6 +671,12 @@ private function variableIdentityRows(PDO $pdo): array /** @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, @@ -455,7 +686,7 @@ private function destinationValueLabels(PDO $pdo): array . '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', - [self::DATASET_ID, 'Destination'], + [self::DATASET_ID, $sourceName], ); return array_map( @@ -491,6 +722,35 @@ private function numericColumn( )); } + /** + * @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_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), + ); + } + /** @return list */ private function tableNames(PDO $pdo): array { From 33b179f4002a951cdd4e4b561d198a575f56a8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 11:52:38 +0300 Subject: [PATCH 05/16] test: capture rejected target label state --- .../task-2-report.md | 27 ++ .../InPlaceTransformationServiceTest.php | 249 ++++++++++++++---- 2 files changed, 220 insertions(+), 56 deletions(-) diff --git a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md index 8541331..94a6824 100644 --- a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md +++ b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md @@ -59,3 +59,30 @@ Observed results: ## Concerns - This shell session has no `OPENSTATSPEC_*` DSN environment variables configured, so the fresh local execution covered SQLite plus the related SQLite executor unit path; PostgreSQL/MySQL/MariaDB/Dolt branches remain encoded in the integration matrix tests for configured environments and CI. + +## Fix Round 1 + +- Extended the rejected create-target evidence to snapshot and re-assert `value_label_set`, `value_label`, and `variable_value_label_set` rows for the fixture dataset before and after the expected `UnsupportedOperation`. +- This closes the review gap where the rejected plan included `SetVariableLabelOperation` and `SetValueLabelsOperation` for `CreatedTarget`, but the unchanged-state proof did not include the value-label catalog tables those operations would touch. + +Commands run: + +```bash +vendor/bin/phpunit --filter "InPlaceTransformationServiceTest|InPlaceTransformationExecutorTest" +php -l tests/Integration/InPlaceTransformationServiceTest.php +git diff --check +``` + +Observed results: + +- `vendor/bin/phpunit --filter "InPlaceTransformationServiceTest|InPlaceTransformationExecutorTest"` + - `OK, but some tests were skipped!` + - `Tests: 42, Assertions: 212, Skipped: 16.` +- `php -l tests/Integration/InPlaceTransformationServiceTest.php` + - `No syntax errors detected` +- `git diff --check` + - no whitespace or patch-shape errors + +Round-specific concern: + +- The MySQL/MariaDB/Dolt rejection branches are still skipped in this shell because their `OPENSTATSPEC_*` DSNs are not configured locally, so the new preflight-rejection value-label assertions remain encoded for configured environments and CI rather than executed here. diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index 685e409..f1f031c 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -37,6 +37,11 @@ final class InPlaceTransformationServiceTest extends TestCase private const DESTINATION_VARIABLE_ID = '018f47f2-8b6a-7c3d-9e1f-123456789abe'; private const IMPORTED_AT = '2026-08-12 00:00:00'; + private int $fixtureSequence = 0; + + /** @var array{dataset_id: string, source_variable_id: string, destination_variable_id: string, table_name: string, dataset_name: string}|null */ + private ?array $activeFixture = null; + /** @return iterable, string|null}> */ public static function services(): iterable { @@ -83,6 +88,31 @@ public function testConfiguredServiceMatchesExpectedProfileAndVersionFamily( $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( @@ -98,10 +128,11 @@ public function testExistingTargetTransformationPreservesPhysicalIdentityInPlace $fixture = $this->installFixture($pdo, $connection); try { + self::assertSame(self::DATASET_ID, $fixture['dataset_id']); $plan = $this->existingTargetPlan(); $result = (new InPlaceTransformationExecutor($connection))->execute($plan); - self::assertSame(self::DATASET_ID, $result->datasetId()); + self::assertSame($fixture['dataset_id'], $result->datasetId()); self::assertSame($plan->hash(), $result->planHash()); self::assertSame(3, $result->operationCount()); @@ -122,14 +153,14 @@ public function testExistingTargetTransformationPreservesPhysicalIdentityInPlace self::assertSame( [ [ - 'variable_id' => self::DESTINATION_VARIABLE_ID, + '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 = ?', - [self::DATASET_ID, 'Destination'], + [$fixture['dataset_id'], 'Destination'], ), ); self::assertSame( @@ -140,11 +171,11 @@ public function testExistingTargetTransformationPreservesPhysicalIdentityInPlace ], $this->destinationValueLabels($pdo), ); - self::assertSame(1, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ?', [self::DATASET_ID])); - self::assertSame(2, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM variable WHERE dataset_id = ?', [self::DATASET_ID])); + self::assertSame(1, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ?', [$fixture['dataset_id']])); + 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); + $this->purgeFixture($pdo, $connection, $fixture); } } @@ -166,7 +197,7 @@ public function testCreateTargetTransformationCreatesNumericTargetInPlace( $plan = $this->createTargetPlan(); $result = (new InPlaceTransformationExecutor($connection))->execute($plan); - self::assertSame(self::DATASET_ID, $result->datasetId()); + self::assertSame($fixture['dataset_id'], $result->datasetId()); self::assertSame($plan->hash(), $result->planHash()); self::assertSame(3, $result->operationCount()); @@ -217,12 +248,12 @@ public function testCreateTargetTransformationCreatesNumericTargetInPlace( ), ); 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 = ?', [self::DATASET_ID])); + 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 = ?', - [self::DATASET_ID, 'CreatedTarget'], + [$fixture['dataset_id'], 'CreatedTarget'], ); self::assertCount(1, $createdTargetRows); self::assertMatchesRegularExpression('/^[0-9a-f-]{36}$/i', (string) $createdTargetRows[0]['variable_id']); @@ -252,10 +283,10 @@ public function testCreateTargetTransformationCreatesNumericTargetInPlace( ], $this->valueLabelsForVariable($pdo, 'CreatedTarget'), ); - self::assertSame(1, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ?', [self::DATASET_ID])); + self::assertSame(1, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ?', [$fixture['dataset_id']])); $this->assertNoArtifactTables($fixture['tables']); } finally { - $this->purgeFixture($pdo, $connection); + $this->purgeFixture($pdo, $connection, $fixture); } } @@ -277,6 +308,9 @@ public function testCreateTargetTransformationRejectsNonAtomicProfilesWithoutCha $before = [ 'dataset' => $this->datasetRow($pdo), 'variables' => $this->variableIdentityRows($pdo), + 'value_label_sets' => $this->valueLabelSetRows($pdo), + 'value_labels' => $this->valueLabelRows($pdo), + 'variable_value_label_sets' => $this->variableValueLabelSetRows($pdo), 'rows' => $this->tableRows( $pdo, $connection, @@ -305,6 +339,9 @@ public function testCreateTargetTransformationRejectsNonAtomicProfilesWithoutCha self::assertSame($before['dataset'], $this->datasetRow($pdo)); self::assertSame($before['variables'], $this->variableIdentityRows($pdo)); + self::assertSame($before['value_label_sets'], $this->valueLabelSetRows($pdo)); + self::assertSame($before['value_labels'], $this->valueLabelRows($pdo)); + self::assertSame($before['variable_value_label_sets'], $this->variableValueLabelSetRows($pdo)); self::assertSame( $before['rows'], $this->tableRows( @@ -320,7 +357,7 @@ public function testCreateTargetTransformationRejectsNonAtomicProfilesWithoutCha self::assertSame($before['variable_count'], (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM variable', [])); $this->assertNoArtifactTables($fixture['tables']); } finally { - $this->purgeFixture($pdo, $connection); + $this->purgeFixture($pdo, $connection, $fixture); } } @@ -336,7 +373,8 @@ public function testFixtureSetupRejectsDirtyDeterministicNamespaceWithoutDeletin unset($expectedVersionFamilies, $expectedVersionEnvironment); $pdo = $this->servicePdo($expectedProfile, $environmentPrefix, $driver); $connection = new Connection($pdo); - $tableName = $this->tableName($connection); + $fixture = $this->reserveFixtureIdentity($connection); + $tableName = $fixture['table_name']; (new NormativeCatalog($pdo))->createTables(); CatalogOwnership::markCurrentVersion($pdo); @@ -348,11 +386,11 @@ public function testFixtureSetupRejectsDirtyDeterministicNamespaceWithoutDeletin 'INSERT INTO dataset ' . '(dataset_id, spec_version, source_format, physical_table_schema, physical_table_name, dataset_name, source_case_count, imported_at) ' . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - )->execute([self::DATASET_ID, '1.0', 'fixture', null, $tableName, 'preexisting deterministic namespace', 0, self::IMPORTED_AT]); + )->execute([$fixture['dataset_id'], '1.0', 'fixture', null, $tableName, 'preexisting deterministic namespace', 0, self::IMPORTED_AT]); try { try { - $this->installFixture($pdo, $connection); + $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()); @@ -360,11 +398,11 @@ public function testFixtureSetupRejectsDirtyDeterministicNamespaceWithoutDeletin self::assertSame( 'preexisting deterministic namespace', - $this->scalar($pdo, 'SELECT dataset_name FROM dataset WHERE dataset_id = ?', [self::DATASET_ID]), + $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); + $this->purgeDirtyNamespaceFixture($pdo, $connection, $fixture); } } @@ -419,13 +457,20 @@ private function versionFamily(string $profile, string $version): string * table_name: string * } */ - private function installFixture(PDO $pdo, Connection $connection): array + /** + * @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 { (new NormativeCatalog($pdo))->createTables(); CatalogOwnership::markCurrentVersion($pdo); - $tableName = $this->tableName($connection); - $this->assertFixtureNamespaceClean($pdo, $connection, $tableName); + $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'); @@ -445,12 +490,12 @@ private function installFixture(PDO $pdo, Connection $connection): array . '(dataset_id, spec_version, source_format, physical_table_schema, physical_table_name, dataset_name, source_case_count, imported_at) ' . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', )->execute([ - self::DATASET_ID, + $fixture['dataset_id'], '1.0', 'fixture', null, $tableName, - $this->fixtureDatasetName($connection), + $fixture['dataset_name'], 5, self::IMPORTED_AT, ]); @@ -461,16 +506,16 @@ private function installFixture(PDO $pdo, Connection $connection): array . 'VALUES (?, ?, ?, ?, ?, ?)', ); $insertVariable->execute([ - self::SOURCE_VARIABLE_ID, - self::DATASET_ID, + $fixture['source_variable_id'], + $fixture['dataset_id'], 1, 'SourceValue', 'source_value', 'numeric', ]); $insertVariable->execute([ - self::DESTINATION_VARIABLE_ID, - self::DATASET_ID, + $fixture['destination_variable_id'], + $fixture['dataset_id'], 2, 'Destination', 'destination', @@ -484,29 +529,32 @@ private function installFixture(PDO $pdo, Connection $connection): array $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), - 'table_name' => $tableName, + ...$fixture, ]; } - private function purgeFixture(PDO $pdo, Connection $connection): void + /** @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 { (new NormativeCatalog($pdo))->createTables(); $dataset = $this->rows( $pdo, 'SELECT physical_table_name, dataset_name, source_format, source_case_count FROM dataset WHERE dataset_id = ?', - [self::DATASET_ID], + [$fixture['dataset_id']], ); if ($dataset !== []) { self::assertCount(1, $dataset); self::assertSame([ - 'physical_table_name' => $this->tableName($connection), - 'dataset_name' => $this->fixtureDatasetName($connection), + 'physical_table_name' => $fixture['table_name'], + 'dataset_name' => $fixture['dataset_name'], 'source_format' => 'fixture', 'source_case_count' => 5, ], [ @@ -519,41 +567,43 @@ private function purgeFixture(PDO $pdo, Connection $connection): void $pdo->prepare( 'DELETE FROM variable_value_label_set WHERE variable_id IN (SELECT variable_id FROM variable WHERE dataset_id = ?)', - )->execute([self::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([self::DATASET_ID]); - $pdo->prepare('DELETE FROM value_label_set WHERE dataset_id = ?')->execute([self::DATASET_ID]); - $pdo->prepare('DELETE FROM variable WHERE dataset_id = ?')->execute([self::DATASET_ID]); - $pdo->prepare('DELETE FROM dataset WHERE dataset_id = ?')->execute([self::DATASET_ID]); - $pdo->exec('DROP TABLE IF EXISTS ' . $this->qualifiedTable($connection, $this->tableName($connection))); + )->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'])); } - private function assertFixtureNamespaceClean(PDO $pdo, Connection $connection, string $tableName): void + /** @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 = ?', [self::DATASET_ID, $tableName]); + $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 = ?', - [self::SOURCE_VARIABLE_ID, self::DESTINATION_VARIABLE_ID, self::DATASET_ID], + [$fixture['source_variable_id'], $fixture['destination_variable_id'], $fixture['dataset_id']], ); - if ($existingRows !== 0 || $existingVariables !== 0 || in_array($tableName, $this->tableNames($pdo), true)) { + 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.'); } } - private function purgeDirtyNamespaceFixture(PDO $pdo, Connection $connection): void + /** @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 { (new NormativeCatalog($pdo))->createTables(); - $datasetName = $this->scalar($pdo, 'SELECT dataset_name FROM dataset WHERE dataset_id = ?', [self::DATASET_ID]); + $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([self::DATASET_ID]); - $pdo->exec('DROP TABLE IF EXISTS ' . $this->qualifiedTable($connection, $this->tableName($connection))); + $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(self::DATASET_ID, [ + return new TransformationPlan($this->activeFixtureId(), [ new RecodeOperation('SourceValue', 'Destination', [ new RecodeRule( new ExactValueSelector(ScalarValue::number(1)), @@ -580,7 +630,7 @@ private function existingTargetPlan(): TransformationPlan private function createTargetPlan(): TransformationPlan { - return new TransformationPlan(self::DATASET_ID, [ + return new TransformationPlan($this->activeFixtureId(), [ new RecodeOperation('SourceValue', 'CreatedTarget', [ new RecodeRule( new ExactValueSelector(ScalarValue::number(1)), @@ -651,7 +701,7 @@ private function datasetRow(PDO $pdo): array $pdo, 'SELECT dataset_id, spec_version, source_format, physical_table_schema, physical_table_name, dataset_name, source_case_count, imported_at ' . 'FROM dataset WHERE dataset_id = ?', - [self::DATASET_ID], + [$this->activeFixtureId()], ); self::assertCount(1, $rows); @@ -665,7 +715,7 @@ private function variableIdentityRows(PDO $pdo): array $pdo, 'SELECT variable_id, source_ordinal, source_name, physical_name, storage_kind, declared_string_width ' . 'FROM variable WHERE dataset_id = ? ORDER BY source_ordinal', - [self::DATASET_ID], + [$this->activeFixtureId()], ); } @@ -686,7 +736,7 @@ private function valueLabelsForVariable(PDO $pdo, string $sourceName): array . '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', - [self::DATASET_ID, $sourceName], + [$this->activeFixtureId(), $sourceName], ); return array_map( @@ -702,6 +752,40 @@ static function (array $row): array { ); } + /** @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 list */ private function numericColumn( PDO $pdo, @@ -735,7 +819,7 @@ private function tableRows(PDO $pdo, Connection $connection, string $tableName, ); self::assertInstanceOf(PDOStatement::class, $statement); - return array_map( + return array_values(array_map( static function (array $row) use ($columns): array { $normalized = []; foreach ($columns as $column) { @@ -748,7 +832,7 @@ static function (array $row) use ($columns): array { return $normalized; }, $statement->fetchAll(PDO::FETCH_ASSOC), - ); + )); } /** @return list */ @@ -810,14 +894,67 @@ private function assertNoArtifactTables(array $tables): void self::assertSame([], $artifactTables); } - private function tableName(Connection $connection): string + /** @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 { - return 'inplace_existing_target_' . $connection->profileName; + 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']; } - private function fixtureDatasetName(Connection $connection): string + /** @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 { - return 'in-place existing-target ' . $connection->profileName; + if ($connection->profileName !== 'dolt') { + return; + } + + $statement = $pdo->prepare('CALL DOLT_COMMIT(?, ?)'); + $statement->execute(['-Am', 'OpenStatSpec in-place fixture ' . $fixture['dataset_id']]); } private function qualifiedTable(Connection $connection, string $tableName): string From 31009be8b19fb62af09703c1021f6ce91869cc31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 11:57:37 +0300 Subject: [PATCH 06/16] test: guard Dolt fixture baseline --- .../InPlaceTransformationServiceTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index f1f031c..5c35a2a 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -463,6 +463,7 @@ private function versionFamily(string $profile, string $version): string */ private function installFixture(PDO $pdo, Connection $connection, ?array $fixture = null): array { + $this->assertDoltWorkingSetCleanBeforeFixture($pdo, $connection); (new NormativeCatalog($pdo))->createTables(); CatalogOwnership::markCurrentVersion($pdo); @@ -543,6 +544,10 @@ private function installFixture(PDO $pdo, Connection $connection, ?array $fixtur /** @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( @@ -594,6 +599,10 @@ private function assertFixtureNamespaceClean(PDO $pdo, Connection $connection, a /** @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); @@ -957,6 +966,23 @@ private function commitDoltFixture(PDO $pdo, Connection $connection, array $fixt $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); From b28e7c906c60b0c106cddd0a0dc249d396a4fa98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 11:59:39 +0300 Subject: [PATCH 07/16] test: baseline Dolt collision fixture --- tests/Integration/InPlaceTransformationServiceTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index 5c35a2a..b3ee0a0 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -376,6 +376,7 @@ public function testFixtureSetupRejectsDirtyDeterministicNamespaceWithoutDeletin $fixture = $this->reserveFixtureIdentity($connection); $tableName = $fixture['table_name']; + $this->assertDoltWorkingSetCleanBeforeFixture($pdo, $connection); (new NormativeCatalog($pdo))->createTables(); CatalogOwnership::markCurrentVersion($pdo); $pdo->exec( @@ -387,6 +388,7 @@ public function testFixtureSetupRejectsDirtyDeterministicNamespaceWithoutDeletin . '(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 { From b0558e7e5d10b1f71956d7e7d78a77ab9538e115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 12:26:06 +0300 Subject: [PATCH 08/16] test: isolate Dolt service fixtures --- .../InPlaceTransformationServiceTest.php | 204 +++++++++++++++++- 1 file changed, 201 insertions(+), 3 deletions(-) diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index b3ee0a0..681a9a8 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -42,6 +42,28 @@ final class InPlaceTransformationServiceTest extends TestCase /** @var array{dataset_id: string, source_variable_id: string, destination_variable_id: string, table_name: string, dataset_name: string}|null */ private ?array $activeFixture = null; + /** @var list */ + 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 { @@ -408,6 +430,41 @@ public function testFixtureSetupRejectsDirtyDeterministicNamespaceWithoutDeletin } } + public function testDoltDatabaseIsolationUsesExclusiveNamesWithoutLosingDsnSettings(): 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' => 'root', + 'password' => 'root', + ], + ], + $this->doltConnectionConfiguration($configuredDsn, 'root', 'root', $firstDatabase), + ); + self::assertSame( + 'mysql:unix_socket=/tmp/dolt.sock;charset=utf8mb4;dbname=' . $secondDatabase, + $this->dsnForDoltDatabase('mysql:unix_socket=/tmp/dolt.sock;charset=utf8mb4', $secondDatabase), + ); + } + /** @param list $expectedVersionFamilies */ private function assertExpectedVersionFamily( Connection $connection, @@ -694,15 +751,156 @@ private function servicePdo(string $expectedProfile, ?string $environmentPrefix, $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'], + $options, + ); + } + return new PDO( $dsn, is_string($user) ? $user : null, is_string($password) ? $password : null, - [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_STRINGIFY_FETCHES => false, + $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, password: string} + * } + */ + private function doltConnectionConfiguration( + string $dsn, + string $adminUser, + string $adminPassword, + string $database, + ): array { + return [ + 'admin' => ['dsn' => $dsn, 'user' => $adminUser, 'password' => $adminPassword], + 'isolated' => [ + 'dsn' => $this->dsnForDoltDatabase($dsn, $database), + 'user' => $adminUser, + 'password' => $adminPassword, ], + ]; + } + + /** @param array $options */ + private function isolatedDoltPdo( + string $dsn, + string $adminUser, + string $adminPassword, + array $options, + ): PDO { + $database = $this->newDoltDatabaseName(); + $configuration = $this->doltConnectionConfiguration($dsn, $adminUser, $adminPassword, $database); + $admin = new PDO( + $configuration['admin']['dsn'], + $configuration['admin']['user'], + $configuration['admin']['password'], + $options, + ); + if ((new Connection($admin))->profileName !== 'dolt') { + throw new RuntimeException('The configured Dolt DSN does not identify a Dolt server.'); + } + + $admin->exec('CREATE DATABASE ' . $this->quoteDoltDatabaseName($database)); + $this->doltTestDatabases[] = ['admin' => $admin, 'database' => $database]; + + $pdo = new PDO( + $configuration['isolated']['dsn'], + $configuration['isolated']['user'], + $configuration['isolated']['password'], + $options, ); + 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 */ From 189f9b5bc23f3012c171739b704c85ed80b3e187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 12:31:46 +0300 Subject: [PATCH 09/16] test: use target credentials for Dolt fixtures --- .../InPlaceTransformationServiceTest.php | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index 681a9a8..4652ec4 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -430,7 +430,7 @@ public function testFixtureSetupRejectsDirtyDeterministicNamespaceWithoutDeletin } } - public function testDoltDatabaseIsolationUsesExclusiveNamesWithoutLosingDsnSettings(): void + public function testDoltDatabaseIsolationSeparatesAdminAndTargetCredentials(): void { self::assertNull($this->doltAdminCredentials(false, 'root')); self::assertNull($this->doltAdminCredentials('root', false)); @@ -453,11 +453,18 @@ public function testDoltDatabaseIsolationUsesExclusiveNamesWithoutLosingDsnSetti 'admin' => ['dsn' => $configuredDsn, 'user' => 'root', 'password' => 'root'], 'isolated' => [ 'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=' . $firstDatabase . ';charset=utf8mb4', - 'user' => 'root', - 'password' => 'root', + 'user' => 'openstatspec', + 'password' => 'target-secret', ], ], - $this->doltConnectionConfiguration($configuredDsn, 'root', 'root', $firstDatabase), + $this->doltConnectionConfiguration( + $configuredDsn, + 'root', + 'root', + 'openstatspec', + 'target-secret', + $firstDatabase, + ), ); self::assertSame( 'mysql:unix_socket=/tmp/dolt.sock;charset=utf8mb4;dbname=' . $secondDatabase, @@ -772,6 +779,8 @@ private function servicePdo(string $expectedProfile, ?string $environmentPrefix, $dsn, $adminCredentials['user'], $adminCredentials['password'], + is_string($user) ? $user : null, + is_string($password) ? $password : null, $options, ); } @@ -797,21 +806,23 @@ private function doltAdminCredentials(string|false $user, string|false $password /** * @return array{ * admin: array{dsn: string, user: string, password: string}, - * isolated: 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' => $adminUser, - 'password' => $adminPassword, + 'user' => $targetUser, + 'password' => $targetPassword, ], ]; } @@ -821,20 +832,25 @@ 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, $database); + $configuration = $this->doltConnectionConfiguration( + $dsn, + $adminUser, + $adminPassword, + $targetUser, + $targetPassword, + $database, + ); $admin = new PDO( $configuration['admin']['dsn'], $configuration['admin']['user'], $configuration['admin']['password'], $options, ); - if ((new Connection($admin))->profileName !== 'dolt') { - throw new RuntimeException('The configured Dolt DSN does not identify a Dolt server.'); - } - $admin->exec('CREATE DATABASE ' . $this->quoteDoltDatabaseName($database)); $this->doltTestDatabases[] = ['admin' => $admin, 'database' => $database]; @@ -844,6 +860,9 @@ private function isolatedDoltPdo( $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; From cffe6946e4840d135f75e17a5dd57ca0b97695a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 12:41:28 +0300 Subject: [PATCH 10/16] test: strengthen target capability boundary evidence --- .../task-2-report.md | 31 ++++++ .../InPlaceTransformationServiceTest.php | 101 +++++++++++++++--- 2 files changed, 118 insertions(+), 14 deletions(-) diff --git a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md index 94a6824..9f47a40 100644 --- a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md +++ b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md @@ -86,3 +86,34 @@ Observed results: Round-specific concern: - The MySQL/MariaDB/Dolt rejection branches are still skipped in this shell because their `OPENSTATSPEC_*` DSNs are not configured locally, so the new preflight-rejection value-label assertions remain encoded for configured environments and CI rather than executed here. + +## Fix Round 1 Follow-up + +- Replaced the rejected-plan snapshots with a full raw catalog snapshot: `SELECT *` for the dataset and variables, plus complete value-label catalog rows. +- Replaced normalized rejected-plan row comparisons with raw PDO rows, retaining the driver-returned value representation and PHP types. +- Added a profile-aware physical SQL type assertion for `createdtarget`; SQLite verifies `REAL` and PostgreSQL verifies `DOUBLE PRECISION` through its catalog. +- Added explicit physical-table-count assertions for both creation and rejection paths. + +## TDD Evidence + +- Temporarily mutated numeric target creation to use the text type. The SQLite create-target test failed as intended with expected `REAL`, actual `TEXT`; the mutation was reverted before verification. + +## Verification + +```bash +vendor/bin/phpunit --filter "InPlaceTransformationServiceTest|InPlaceTransformationExecutorTest" +php -l tests/Integration/InPlaceTransformationServiceTest.php +composer lint +composer analyse +composer style +git diff --check +``` + +- Focused PHPUnit: `Tests: 43, Assertions: 228, Skipped: 16.` +- PHP lint and PHPStan passed. +- `git diff --check` passed. +- `composer style` remains non-zero because the repository baseline has CRLF line endings in all 146 checked files; its dry-run output proposes only line-ending changes, including untouched files. + +## Concern + +- PostgreSQL/MySQL/MariaDB/Dolt DSNs are not configured locally. Their matrix cases remain encoded but skipped in this shell. diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index 4652ec4..56eedcf 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -229,6 +229,7 @@ public function testCreateTargetTransformationCreatesNumericTargetInPlace( [...$fixture['columns'], 'createdtarget'], $this->tableColumns($pdo, $connection, $fixture['table_name']), ); + $this->assertProfileNumericColumnType($pdo, $connection, $fixture['table_name'], 'createdtarget'); self::assertSame( [ [ @@ -306,6 +307,7 @@ public function testCreateTargetTransformationCreatesNumericTargetInPlace( $this->valueLabelsForVariable($pdo, 'CreatedTarget'), ); self::assertSame(1, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ?', [$fixture['dataset_id']])); + self::assertSame(count($fixture['tables']), count($this->tableNames($pdo))); $this->assertNoArtifactTables($fixture['tables']); } finally { $this->purgeFixture($pdo, $connection, $fixture); @@ -328,12 +330,8 @@ public function testCreateTargetTransformationRejectsNonAtomicProfilesWithoutCha try { $before = [ - 'dataset' => $this->datasetRow($pdo), - 'variables' => $this->variableIdentityRows($pdo), - 'value_label_sets' => $this->valueLabelSetRows($pdo), - 'value_labels' => $this->valueLabelRows($pdo), - 'variable_value_label_sets' => $this->variableValueLabelSetRows($pdo), - 'rows' => $this->tableRows( + 'catalog' => $this->fullCatalogSnapshot($pdo), + 'rows' => $this->rawTableRows( $pdo, $connection, $fixture['table_name'], @@ -341,6 +339,7 @@ public function testCreateTargetTransformationRejectsNonAtomicProfilesWithoutCha ), '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', []), ]; @@ -359,14 +358,10 @@ public function testCreateTargetTransformationRejectsNonAtomicProfilesWithoutCha ); } - self::assertSame($before['dataset'], $this->datasetRow($pdo)); - self::assertSame($before['variables'], $this->variableIdentityRows($pdo)); - self::assertSame($before['value_label_sets'], $this->valueLabelSetRows($pdo)); - self::assertSame($before['value_labels'], $this->valueLabelRows($pdo)); - self::assertSame($before['variable_value_label_sets'], $this->variableValueLabelSetRows($pdo)); + self::assertSame($before['catalog'], $this->fullCatalogSnapshot($pdo)); self::assertSame( $before['rows'], - $this->tableRows( + $this->rawTableRows( $pdo, $connection, $fixture['table_name'], @@ -375,6 +370,7 @@ public function testCreateTargetTransformationRejectsNonAtomicProfilesWithoutCha ); 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', [])); $this->assertNoArtifactTables($fixture['tables']); @@ -927,8 +923,7 @@ private function datasetRow(PDO $pdo): array { $rows = $this->rows( $pdo, - 'SELECT dataset_id, spec_version, source_format, physical_table_schema, physical_table_name, dataset_name, source_case_count, imported_at ' - . 'FROM dataset WHERE dataset_id = ?', + 'SELECT * FROM dataset WHERE dataset_id = ?', [$this->activeFixtureId()], ); self::assertCount(1, $rows); @@ -1014,6 +1009,26 @@ private function variableValueLabelSetRows(PDO $pdo): array ); } + /** @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, @@ -1063,6 +1078,24 @@ static function (array $row) use ($columns): array { )); } + /** + * 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 { @@ -1111,6 +1144,46 @@ private function tableColumns(PDO $pdo, Connection $connection, string $tableNam )); } + 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.'), + }; + + 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 list $tables */ private function assertNoArtifactTables(array $tables): void { From baeffbaefd7a2f6a8cf336c34b1b6458fdc45f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 12:53:59 +0300 Subject: [PATCH 11/16] test: assert Dolt in-place repository evidence --- .../task-3-report.md | 44 ++++ .../InPlaceTransformationServiceTest.php | 189 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 .superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-3-report.md diff --git a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-3-report.md b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-3-report.md new file mode 100644 index 0000000..395c7ba --- /dev/null +++ b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-3-report.md @@ -0,0 +1,44 @@ +# Task 3 report: Dolt repository-state assertions + +## Status + +Implemented the Task 3 test-only evidence in `tests/Integration/InPlaceTransformationServiceTest.php`. No production code, Dolt branch, reset, or commit behavior was changed. + +## Changes + +- Added a read-only Dolt repository probe for active branch, `HEAD`, reachable commit hashes, full `dolt_status` rows, and `WORKING` rows from `dolt_diff`. +- The existing-target success case now proves the fixture baseline is clean, branch/HEAD/history remain unchanged, and exactly the expected five tables remain as unstaged, data-only working-set changes. +- The create-target rejection case now snapshots and compares Dolt repository state alongside rows, full fixture catalog, physical columns, counts, and table names. +- Added deterministic normalization coverage for PDO string/integer boolean values so the probe is exercised without a live Dolt service. +- Preserved Task 1 isolation: admin credentials are used only for database create/drop; target credentials run fixtures and executor; fixture setup creates one approved baseline commit; teardown drops the isolated database after assertions. + +## TDD evidence + +- RED: the normalization test first errored because the helper did not exist; after adding only a placeholder, it failed with the intended expected-array versus empty-array mismatch. +- GREEN: the minimal normalization implementation passed: 1 test, 1 assertion. +- Focused class/executor/guard run: 49 tests, 240 assertions, 16 skipped, 0 failures. + +## Dolt execution disclosure + +`OPENSTATSPEC_DOLT_DSN`, target user/password, and admin user/password are absent locally. The Dolt-backed provider cases therefore remain explicit PHPUnit skips; no live Dolt result is reported as green. The deterministic normalization test and existing executor guard tests ran locally. + +## Verification + +- Focused PHPUnit: pass (49 tests, 240 assertions, 16 external-service skips). +- Full PHPUnit: pass (246 tests, 1,725 assertions, 30 external-service skips). +- PHP lint: pass. +- PHPStan: pass with no errors. +- Targeted PHP CS Fixer dry run for the changed PHP file: pass. +- Repository-wide PHP CS Fixer dry run: non-zero because 145 untouched PHP files are checked out with CRLF line endings; its output proposes line-ending-only changes. This is the same pre-existing baseline issue recorded in the Task 2 report. +- `git diff --check`: pass. + +## Concerns + +- Live Dolt 2.2.x must execute the skipped provider cases in configured CI to validate the exact system-table rows against the service. +- The success assertion intentionally expects five data-only dirty tables: the physical wide table, `variable`, `value_label_set`, `value_label`, and `variable_value_label_set`. + +## Review fix + +- Read-only review against Dolt 2.2.3 found that `dolt_log.commit_order` must be sorted descending to place `HEAD` first. The probe query now uses `ORDER BY commit_order DESC, commit_hash`; this preserves stable history comparison and makes the pre-apply `HEAD` assertion valid. +- The reviewer reran the two affected Dolt provider cases after the ordering fix: both passed (`8 tests, 85 assertions, 5 unrelated provider skips`). +- That live run exposed uppercase `COLUMN_NAME` metadata from Dolt. Added deterministic key-case normalization coverage and applied it to MySQL-family column metadata so schema assertions read real column names without warnings; the final live rerun passed both cases with zero warnings. diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index 56eedcf..cd61b7c 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -151,9 +151,49 @@ public function testExistingTargetTransformationPreservesPhysicalIdentityInPlace try { self::assertSame(self::DATASET_ID, $fixture['dataset_id']); + $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()); @@ -342,6 +382,7 @@ public function testCreateTargetTransformationRejectsNonAtomicProfilesWithoutCha '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 { @@ -373,6 +414,7 @@ public function testCreateTargetTransformationRejectsNonAtomicProfilesWithoutCha 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); @@ -468,6 +510,48 @@ public function testDoltDatabaseIsolationSeparatesAdminAndTargetCredentials(): v ); } + 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, @@ -1138,6 +1222,10 @@ private function tableColumns(PDO $pdo, Connection $connection, string $tableNam )); } + if ($driver === 'mysql') { + $rows = array_map($this->normalizeMySqlMetadataRow(...), $rows); + } + return array_values(array_map( static fn(array $column): string => (string) $column['column_name'], $rows, @@ -1162,6 +1250,10 @@ private function assertProfileNumericColumnType(PDO $pdo, Connection $connection 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) { @@ -1184,6 +1276,103 @@ 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 { From af8baec21ca9548420edcad68bf49454ed3c2cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 13:10:19 +0300 Subject: [PATCH 12/16] ci: run PHP in-place evidence across services --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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" From e3faab27b47ccd855731fb38eb2aa706a13b52fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 13:15:37 +0300 Subject: [PATCH 13/16] docs: document PHP in-place evidence gate --- .../task-5-report.md | 57 +++++++++++++++++++ CHANGELOG.md | 9 +++ docs/release-readiness.md | 25 ++++++-- docs/transformations.md | 27 +++++++++ 4 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 .superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md diff --git a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md new file mode 100644 index 0000000..94924f4 --- /dev/null +++ b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md @@ -0,0 +1,57 @@ +# Task 5 report: PHP in-place service-matrix documentation + +## Status + +Documented the PHP adapter service-matrix gate in the requested three +documentation files. The wording preserves the package-local legacy +`openstatspec-transformation-plan-v1` disclaimer and explicitly makes no +official OpenStatSpec Transformation Plan 1.0 profile claim. + +## Documentation coverage + +- Provides the focused command: + `OPENSTATSPEC_SPECIFICATION_DIR=/path/to/openstatspec-specification vendor/bin/phpunit tests/Integration/InPlaceTransformationServiceTest.php`. +- States that SQLite runs locally; an unavailable local PostgreSQL, MySQL, + MariaDB, or Dolt profile skips, while CI supplies configuration and must run + every service profile. +- States that existing-target evidence preserves the dataset UUID, physical + table, and existing variable identities without copied, staging, snapshot, + rollback, or parallel-history tables. +- States the create-target boundary: only SQLite/PostgreSQL evidence implicit + numeric targets; MySQL/MariaDB/Dolt reject implicit creation before mutation + and require a pre-created, catalogued target. + +## Verification + +- Focused service matrix: pass — 24 tests, 91 assertions, 16 expected skips. +- Full `composer test` with + `OPENSTATSPEC_SPECIFICATION_DIR=/home/tonis/PhpstormProjects/survey-db/specification`: + pass — 246 tests, 1,725 assertions, 30 expected skips. +- `composer validate --strict`, `composer lint`, and `composer analyse`: pass. +- `git diff --check` for the documentation patch and complete matrix branch: + pass. +- `composer style`: non-zero baseline issue. PHP CS Fixer reports 145 of 146 + PHP files as line-ending-only CRLF changes, including untouched files; no + formatter change was made. + +## Local-profile disclosure + +- Ran: SQLite. +- Skipped: PostgreSQL (the local PHP runtime lacks `pdo_pgsql`), MySQL, + MariaDB, and Dolt (their `OPENSTATSPEC_*_DSN` values are not configured). + +## Branch-diff review + +Reviewed `d83c20e..HEAD`. Fixtures use deterministic per-test identities and +the Dolt path creates/drops only generated, random-suffixed isolated databases; +there is no shared-schema drop, truncate, reset, or shared-state cleanup. +The existing-target identity and create-target boundary assertions cover the +approved requirements, and CI invokes the evidence class for every service. + +## Concern for local code review + +The test fixture implementation creates a baseline Dolt commit in its isolated +test database (`commitDoltFixture`), although the plan's global constraints +say not to create Dolt commits as evidence setup. This does not affect a shared +Dolt repository, but the local code-review loop should decide whether the +isolated baseline commit is an acceptable exception before a PR is published. 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/transformations.md b/docs/transformations.md index cd83a7b..bd65fa1 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 a +recode plus label changes preserves 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 From cab5e4c33d98c5e0d95d5a03dc0282ef967c7c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 13:18:38 +0300 Subject: [PATCH 14/16] docs: clarify PHP in-place evidence gate --- .../task-5-report.md | 19 +++++++++++++------ docs/transformations.md | 4 ++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md index 94924f4..303b48f 100644 --- a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md +++ b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md @@ -48,10 +48,17 @@ there is no shared-schema drop, truncate, reset, or shared-state cleanup. The existing-target identity and create-target boundary assertions cover the approved requirements, and CI invokes the evidence class for every service. -## Concern for local code review +## Approved Dolt fixture boundary -The test fixture implementation creates a baseline Dolt commit in its isolated -test database (`commitDoltFixture`), although the plan's global constraints -say not to create Dolt commits as evidence setup. This does not affect a shared -Dolt repository, but the local code-review loop should decide whether the -isolated baseline commit is an acceptable exception before a PR is published. +Human review approved the fixture's baseline Dolt commit. It is created only +in a generated, random-suffixed isolated test database to establish the clean +baseline needed for repository-state assertions; it does not affect a shared +repository. The executor remains separately evidenced never to create a Dolt +commit, reset `HEAD`, or change the active branch. + +## Fix round 1 + +- Recorded the approved isolated-fixture baseline commit boundary and the + executor's no-commit/no-reset/no-branch-change guarantee. +- Corrected the service-matrix statement so that recode plus label changes + grammatically and explicitly preserve existing-target identities. diff --git a/docs/transformations.md b/docs/transformations.md index bd65fa1..1a2b1ca 100644 --- a/docs/transformations.md +++ b/docs/transformations.md @@ -148,8 +148,8 @@ 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 a -recode plus label changes preserves the dataset UUID, physical table identity, +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; From b6f79e6592684a5f2ffecd5985dff7efcf667d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 13:33:21 +0300 Subject: [PATCH 15/16] test: enforce global in-place dataset identity --- .gitignore | 1 + .../task-2-report.md | 119 ------------------ .../task-3-report.md | 44 ------- .../task-5-report.md | 64 ---------- .../InPlaceTransformationServiceTest.php | 6 +- 5 files changed, 5 insertions(+), 229 deletions(-) delete mode 100644 .superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md delete mode 100644 .superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-3-report.md delete mode 100644 .superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md 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/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md deleted file mode 100644 index 9f47a40..0000000 --- a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-2-report.md +++ /dev/null @@ -1,119 +0,0 @@ -# Task 2 Report - -## Scope - -Implemented Task 2 only in `tests/Integration/InPlaceTransformationServiceTest.php`, extending the existing Task 1 service-matrix fixture coverage without changing CI, docs, or production code. - -## What Changed - -- Added `createTargetCapableServices()` for SQLite and PostgreSQL success evidence. -- Added `createTargetRejectedServices()` for MySQL, MariaDB, and Dolt preflight rejection evidence. -- Added `testCreateTargetTransformationCreatesNumericTargetInPlace()` to prove: - - the dataset row and physical table identity stay unchanged, - - a new numeric target catalog variable is created, - - a new physical column is appended in-place, - - recoded values land in the new column, - - metadata operations against the implicit target succeed. -- Added `testCreateTargetTransformationRejectsNonAtomicProfilesWithoutChangingState()` to prove: - - MySQL-family profiles reject the plan during preflight with `TargetCapabilityExceeded`, - - data rows, dataset row, variable catalog rows, column list, table list, and dataset/variable counts remain unchanged. -- Added `createTargetPlan()` and small helper extensions for reusable value-label and row snapshots. - -## TDD Notes - -- Wrote the new evidence tests first. -- Ran the focused test target immediately after adding them. -- Result: the new tests passed on the currently reachable local backend set, so no production defect was exposed and no source changes were necessary. - -## Verification - -Commands run: - -```bash -vendor/bin/phpunit --filter InPlaceTransformationServiceTest -vendor/bin/phpunit --filter "InPlaceTransformationServiceTest|InPlaceTransformationExecutorTest" -php -l tests/Integration/InPlaceTransformationServiceTest.php -git diff --check -``` - -Observed results: - -- `vendor/bin/phpunit --filter InPlaceTransformationServiceTest` - - `OK, but some tests were skipped!` - - `Tests: 20, Assertions: 57, Skipped: 16.` -- `vendor/bin/phpunit --filter "InPlaceTransformationServiceTest|InPlaceTransformationExecutorTest"` - - `OK, but some tests were skipped!` - - `Tests: 41, Assertions: 196, Skipped: 16.` -- `php -l tests/Integration/InPlaceTransformationServiceTest.php` - - `No syntax errors detected` -- `git diff --check` - - no whitespace or patch-shape errors - -## Self-Review - -- Verified the change stays inside the required integration test class. -- Verified the rejection test asserts the documented preflight exception message from the in-place executor capability guard. -- Verified the success test proves unchanged dataset/table identity while checking the newly created catalog variable, physical column, and recoded values. -- Verified no CI or documentation files were modified. - -## Concerns - -- This shell session has no `OPENSTATSPEC_*` DSN environment variables configured, so the fresh local execution covered SQLite plus the related SQLite executor unit path; PostgreSQL/MySQL/MariaDB/Dolt branches remain encoded in the integration matrix tests for configured environments and CI. - -## Fix Round 1 - -- Extended the rejected create-target evidence to snapshot and re-assert `value_label_set`, `value_label`, and `variable_value_label_set` rows for the fixture dataset before and after the expected `UnsupportedOperation`. -- This closes the review gap where the rejected plan included `SetVariableLabelOperation` and `SetValueLabelsOperation` for `CreatedTarget`, but the unchanged-state proof did not include the value-label catalog tables those operations would touch. - -Commands run: - -```bash -vendor/bin/phpunit --filter "InPlaceTransformationServiceTest|InPlaceTransformationExecutorTest" -php -l tests/Integration/InPlaceTransformationServiceTest.php -git diff --check -``` - -Observed results: - -- `vendor/bin/phpunit --filter "InPlaceTransformationServiceTest|InPlaceTransformationExecutorTest"` - - `OK, but some tests were skipped!` - - `Tests: 42, Assertions: 212, Skipped: 16.` -- `php -l tests/Integration/InPlaceTransformationServiceTest.php` - - `No syntax errors detected` -- `git diff --check` - - no whitespace or patch-shape errors - -Round-specific concern: - -- The MySQL/MariaDB/Dolt rejection branches are still skipped in this shell because their `OPENSTATSPEC_*` DSNs are not configured locally, so the new preflight-rejection value-label assertions remain encoded for configured environments and CI rather than executed here. - -## Fix Round 1 Follow-up - -- Replaced the rejected-plan snapshots with a full raw catalog snapshot: `SELECT *` for the dataset and variables, plus complete value-label catalog rows. -- Replaced normalized rejected-plan row comparisons with raw PDO rows, retaining the driver-returned value representation and PHP types. -- Added a profile-aware physical SQL type assertion for `createdtarget`; SQLite verifies `REAL` and PostgreSQL verifies `DOUBLE PRECISION` through its catalog. -- Added explicit physical-table-count assertions for both creation and rejection paths. - -## TDD Evidence - -- Temporarily mutated numeric target creation to use the text type. The SQLite create-target test failed as intended with expected `REAL`, actual `TEXT`; the mutation was reverted before verification. - -## Verification - -```bash -vendor/bin/phpunit --filter "InPlaceTransformationServiceTest|InPlaceTransformationExecutorTest" -php -l tests/Integration/InPlaceTransformationServiceTest.php -composer lint -composer analyse -composer style -git diff --check -``` - -- Focused PHPUnit: `Tests: 43, Assertions: 228, Skipped: 16.` -- PHP lint and PHPStan passed. -- `git diff --check` passed. -- `composer style` remains non-zero because the repository baseline has CRLF line endings in all 146 checked files; its dry-run output proposes only line-ending changes, including untouched files. - -## Concern - -- PostgreSQL/MySQL/MariaDB/Dolt DSNs are not configured locally. Their matrix cases remain encoded but skipped in this shell. diff --git a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-3-report.md b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-3-report.md deleted file mode 100644 index 395c7ba..0000000 --- a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-3-report.md +++ /dev/null @@ -1,44 +0,0 @@ -# Task 3 report: Dolt repository-state assertions - -## Status - -Implemented the Task 3 test-only evidence in `tests/Integration/InPlaceTransformationServiceTest.php`. No production code, Dolt branch, reset, or commit behavior was changed. - -## Changes - -- Added a read-only Dolt repository probe for active branch, `HEAD`, reachable commit hashes, full `dolt_status` rows, and `WORKING` rows from `dolt_diff`. -- The existing-target success case now proves the fixture baseline is clean, branch/HEAD/history remain unchanged, and exactly the expected five tables remain as unstaged, data-only working-set changes. -- The create-target rejection case now snapshots and compares Dolt repository state alongside rows, full fixture catalog, physical columns, counts, and table names. -- Added deterministic normalization coverage for PDO string/integer boolean values so the probe is exercised without a live Dolt service. -- Preserved Task 1 isolation: admin credentials are used only for database create/drop; target credentials run fixtures and executor; fixture setup creates one approved baseline commit; teardown drops the isolated database after assertions. - -## TDD evidence - -- RED: the normalization test first errored because the helper did not exist; after adding only a placeholder, it failed with the intended expected-array versus empty-array mismatch. -- GREEN: the minimal normalization implementation passed: 1 test, 1 assertion. -- Focused class/executor/guard run: 49 tests, 240 assertions, 16 skipped, 0 failures. - -## Dolt execution disclosure - -`OPENSTATSPEC_DOLT_DSN`, target user/password, and admin user/password are absent locally. The Dolt-backed provider cases therefore remain explicit PHPUnit skips; no live Dolt result is reported as green. The deterministic normalization test and existing executor guard tests ran locally. - -## Verification - -- Focused PHPUnit: pass (49 tests, 240 assertions, 16 external-service skips). -- Full PHPUnit: pass (246 tests, 1,725 assertions, 30 external-service skips). -- PHP lint: pass. -- PHPStan: pass with no errors. -- Targeted PHP CS Fixer dry run for the changed PHP file: pass. -- Repository-wide PHP CS Fixer dry run: non-zero because 145 untouched PHP files are checked out with CRLF line endings; its output proposes line-ending-only changes. This is the same pre-existing baseline issue recorded in the Task 2 report. -- `git diff --check`: pass. - -## Concerns - -- Live Dolt 2.2.x must execute the skipped provider cases in configured CI to validate the exact system-table rows against the service. -- The success assertion intentionally expects five data-only dirty tables: the physical wide table, `variable`, `value_label_set`, `value_label`, and `variable_value_label_set`. - -## Review fix - -- Read-only review against Dolt 2.2.3 found that `dolt_log.commit_order` must be sorted descending to place `HEAD` first. The probe query now uses `ORDER BY commit_order DESC, commit_hash`; this preserves stable history comparison and makes the pre-apply `HEAD` assertion valid. -- The reviewer reran the two affected Dolt provider cases after the ordering fix: both passed (`8 tests, 85 assertions, 5 unrelated provider skips`). -- That live run exposed uppercase `COLUMN_NAME` metadata from Dolt. Added deterministic key-case normalization coverage and applied it to MySQL-family column metadata so schema assertions read real column names without warnings; the final live rerun passed both cases with zero warnings. diff --git a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md b/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md deleted file mode 100644 index 303b48f..0000000 --- a/.superpowers/sdd/2026-08-12-php-inplace-service-matrix/task-5-report.md +++ /dev/null @@ -1,64 +0,0 @@ -# Task 5 report: PHP in-place service-matrix documentation - -## Status - -Documented the PHP adapter service-matrix gate in the requested three -documentation files. The wording preserves the package-local legacy -`openstatspec-transformation-plan-v1` disclaimer and explicitly makes no -official OpenStatSpec Transformation Plan 1.0 profile claim. - -## Documentation coverage - -- Provides the focused command: - `OPENSTATSPEC_SPECIFICATION_DIR=/path/to/openstatspec-specification vendor/bin/phpunit tests/Integration/InPlaceTransformationServiceTest.php`. -- States that SQLite runs locally; an unavailable local PostgreSQL, MySQL, - MariaDB, or Dolt profile skips, while CI supplies configuration and must run - every service profile. -- States that existing-target evidence preserves the dataset UUID, physical - table, and existing variable identities without copied, staging, snapshot, - rollback, or parallel-history tables. -- States the create-target boundary: only SQLite/PostgreSQL evidence implicit - numeric targets; MySQL/MariaDB/Dolt reject implicit creation before mutation - and require a pre-created, catalogued target. - -## Verification - -- Focused service matrix: pass — 24 tests, 91 assertions, 16 expected skips. -- Full `composer test` with - `OPENSTATSPEC_SPECIFICATION_DIR=/home/tonis/PhpstormProjects/survey-db/specification`: - pass — 246 tests, 1,725 assertions, 30 expected skips. -- `composer validate --strict`, `composer lint`, and `composer analyse`: pass. -- `git diff --check` for the documentation patch and complete matrix branch: - pass. -- `composer style`: non-zero baseline issue. PHP CS Fixer reports 145 of 146 - PHP files as line-ending-only CRLF changes, including untouched files; no - formatter change was made. - -## Local-profile disclosure - -- Ran: SQLite. -- Skipped: PostgreSQL (the local PHP runtime lacks `pdo_pgsql`), MySQL, - MariaDB, and Dolt (their `OPENSTATSPEC_*_DSN` values are not configured). - -## Branch-diff review - -Reviewed `d83c20e..HEAD`. Fixtures use deterministic per-test identities and -the Dolt path creates/drops only generated, random-suffixed isolated databases; -there is no shared-schema drop, truncate, reset, or shared-state cleanup. -The existing-target identity and create-target boundary assertions cover the -approved requirements, and CI invokes the evidence class for every service. - -## Approved Dolt fixture boundary - -Human review approved the fixture's baseline Dolt commit. It is created only -in a generated, random-suffixed isolated test database to establish the clean -baseline needed for repository-state assertions; it does not affect a shared -repository. The executor remains separately evidenced never to create a Dolt -commit, reset `HEAD`, or change the active branch. - -## Fix round 1 - -- Recorded the approved isolated-fixture baseline commit boundary and the - executor's no-commit/no-reset/no-branch-change guarantee. -- Corrected the service-matrix statement so that recode plus label changes - grammatically and explicitly preserve existing-target identities. diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index cd61b7c..d42c2da 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -151,6 +151,7 @@ public function testExistingTargetTransformationPreservesPhysicalIdentityInPlace 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); @@ -233,7 +234,7 @@ public function testExistingTargetTransformationPreservesPhysicalIdentityInPlace ], $this->destinationValueLabels($pdo), ); - self::assertSame(1, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ?', [$fixture['dataset_id']])); + 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 { @@ -256,6 +257,7 @@ public function testCreateTargetTransformationCreatesNumericTargetInPlace( $fixture = $this->installFixture($pdo, $connection); try { + $datasetCountBefore = (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset', []); $plan = $this->createTargetPlan(); $result = (new InPlaceTransformationExecutor($connection))->execute($plan); @@ -346,7 +348,7 @@ public function testCreateTargetTransformationCreatesNumericTargetInPlace( ], $this->valueLabelsForVariable($pdo, 'CreatedTarget'), ); - self::assertSame(1, (int) $this->scalar($pdo, 'SELECT COUNT(*) FROM dataset WHERE dataset_id = ?', [$fixture['dataset_id']])); + 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 { From 6d55f3ad2333e45ff3b3eb1556d54d710cf2326f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 17 Aug 2026 13:47:42 +0300 Subject: [PATCH 16/16] test: grant target access to isolated Dolt databases --- tests/Integration/InPlaceTransformationServiceTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Integration/InPlaceTransformationServiceTest.php b/tests/Integration/InPlaceTransformationServiceTest.php index d42c2da..0886a29 100644 --- a/tests/Integration/InPlaceTransformationServiceTest.php +++ b/tests/Integration/InPlaceTransformationServiceTest.php @@ -935,6 +935,16 @@ private function isolatedDoltPdo( ); $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'],