From 2c6b4d366ad0b1671c039a4709f7ea370f5fb358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 9 Nov 2026 15:08:31 +0200 Subject: [PATCH 1/2] feat: restore PHP 8.3 support The 8.4.1 minimum came with the v3 tooling update; the library only used new-without-parentheses and array_all(). Replace them with PHP 8.3 equivalents, accept PHPUnit 12.5 and add a PHP 8.3 lowest-dependency CI job running syntax checks and the full suite. --- .github/workflows/qa.yml | 27 +++++++++++++++++++ CHANGELOG.md | 6 +++++ README.md | 2 +- composer.json | 4 +-- composer.lock | 4 +-- docs/index.md | 2 +- src/Sav/DatasetAssembler.php | 8 +++++- src/Sav/Reader.php | 2 +- src/Sav/Record.php | 2 +- tests/HeaderValidationTest.php | 4 +-- tests/InfoAttributeRecordsTest.php | 10 +++---- tests/InfoSetRecordsTest.php | 10 +++---- tests/LongStringValueLabelsValidationTest.php | 8 +++--- tests/MalformedContainerTest.php | 2 +- tests/RecordCountValidationTest.php | 4 +-- tools/run-branch-coverage.php | 4 +-- 16 files changed, 69 insertions(+), 30 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index fc2a701..e5b2fbb 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -45,6 +45,33 @@ jobs: if: matrix.dependencies == 'lowest' run: composer qa:lowest + # PHP 8.3 is a supported runtime; static analysis and tooling gates run on 8.4+ where PHPUnit 13 is available. + php83-runtime: + name: PHP 8.3 / lowest dependencies runtime + runs-on: ubuntu-latest + + steps: + - name: Check out the repository + uses: actions/checkout@v7 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: bcmath, mbstring, zlib + coverage: none + tools: composer:v2 + + - name: Install lowest supported dependencies for PHP 8.3 + run: | + composer config platform.php 8.3.0 + composer update --no-interaction --prefer-dist --prefer-lowest + + - name: Run syntax and unit tests + run: | + composer test:syntax + composer test:unit + quality-gates: name: Coverage and mutation gates runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 993ac6a..8fb4c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 3.2.0 (2026-11-09) + +- Restored PHP 8.3 support. The minimum had been raised to 8.4.1 with the v3 tooling update, but the library only used three PHP 8.4 constructs (`new` without parentheses in two places and `array_all()`); these are replaced with PHP 8.3 equivalents without behaviour changes. +- PHPUnit 12.5 is accepted for PHP 8.3; CI adds a PHP 8.3 lowest-dependency job that runs syntax checks and the full test suite. Static analysis and coverage/mutation gates continue on PHP 8.4+. +- No runtime API or SAV/ZSAV format changes. + ## 3.1.2 (2026-11-09) - Fixed the very long string record (type 7, subtype 14): every `NAME=WIDTH` entry, including the last one, now ends with NUL + tab as SPSS and PSPP write it. IBM SPSS Statistics previously ignored the record and showed each 252-byte segment of a string wider than 255 bytes as a separate variable (`NAME_A`, `NAME_B`, ...). diff --git a/README.md b/README.md index 0b340dd..fd5cd33 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Maintained by [OpenStatSpec](https://github.com/OpenStatSpec). This project cont ## Requirements -- PHP 8.4.1 or newer +- PHP 8.3 or newer - mbstring extension - bcmath extension - zlib extension diff --git a/composer.json b/composer.json index b3cdcd3..fedd69a 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "source": "https://github.com/OpenStatSpec/php-spss" }, "require": { - "php": "^8.4.1", + "php": "^8.3", "ext-bcmath": "*", "ext-mbstring": "*", "ext-zlib": "*" @@ -45,7 +45,7 @@ "phpstan/phpstan": "^2.2", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^13.2", + "phpunit/phpunit": "^12.5 || ^13.2", "rector/rector": "^2.5" }, "autoload": { diff --git a/composer.lock b/composer.lock index 74e8958..0dde93f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6a0d9179d6d9519155b342e91a019dc9", + "content-hash": "cec70b92849a9de25f60ac1092210549", "packages": [], "packages-dev": [ { @@ -6137,7 +6137,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8.4.1", + "php": "^8.3", "ext-bcmath": "*", "ext-mbstring": "*", "ext-zlib": "*" diff --git a/docs/index.md b/docs/index.md index 5ee518e..00f3bce 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ PHP-SPSS v3 provides a typed, row-oriented model for reading, inspecting, creati ## Requirements and file support -- PHP 8.4.1 or newer +- PHP 8.3 or newer - `mbstring`, `bcmath`, and `zlib` extensions - unencrypted SAV (`$FL2`) and ZSAV (`$FL3`) files diff --git a/src/Sav/DatasetAssembler.php b/src/Sav/DatasetAssembler.php index feaea45..57db3a1 100644 --- a/src/Sav/DatasetAssembler.php +++ b/src/Sav/DatasetAssembler.php @@ -564,7 +564,13 @@ private function allNumeric(array $variableNames, VariableDictionary $dictionary if ([] === $variableNames) { return false; } - return array_all($variableNames, fn(string $variableName): bool => VariableType::NUMERIC === $dictionary->variable($variableName)?->type); + foreach ($variableNames as $variableName) { + if (VariableType::NUMERIC !== $dictionary->variable($variableName)?->type) { + return false; + } + } + + return true; } /** @return array */ diff --git a/src/Sav/Reader.php b/src/Sav/Reader.php index 6b1a68f..a0b0034 100644 --- a/src/Sav/Reader.php +++ b/src/Sav/Reader.php @@ -171,7 +171,7 @@ public function toDataset(bool $includeData = true): Dataset throw new \LogicException('Reader data must be loaded before including rows in a dataset.'); } - return new DatasetAssembler()->assemble($this, $includeData); + return (new DatasetAssembler())->assemble($this, $includeData); } public function readHeader(): static diff --git a/src/Sav/Record.php b/src/Sav/Record.php index 9e477a1..38a260d 100644 --- a/src/Sav/Record.php +++ b/src/Sav/Record.php @@ -22,7 +22,7 @@ public function __construct(array $data = []) throw new \InvalidArgumentException(sprintf('Unknown %s property "%s".', static::class, $key)); } - new \ReflectionProperty($this, $property)->setValue($this, $value); + (new \ReflectionProperty($this, $property))->setValue($this, $value); } } diff --git a/tests/HeaderValidationTest.php b/tests/HeaderValidationTest.php index b313ac9..269a658 100644 --- a/tests/HeaderValidationTest.php +++ b/tests/HeaderValidationTest.php @@ -51,11 +51,11 @@ public function testZlibCompressionRequiresFl3(): void public function testValidCompressionHeaderPairsCanBeWritten(): void { $savBuffer = Buffer::factory(); - new Header(['recType' => Header::NORMAL_REC_TYPE, 'compression' => 1])->write($savBuffer); + (new Header(['recType' => Header::NORMAL_REC_TYPE, 'compression' => 1]))->write($savBuffer); $this->assertSame(176, $savBuffer->position()); $zsavBuffer = Buffer::factory(); - new Header(['recType' => Header::ZLIB_REC_TYPE, 'compression' => 2])->write($zsavBuffer); + (new Header(['recType' => Header::ZLIB_REC_TYPE, 'compression' => 2]))->write($zsavBuffer); $this->assertSame(176, $zsavBuffer->position()); } } diff --git a/tests/InfoAttributeRecordsTest.php b/tests/InfoAttributeRecordsTest.php index 8c22f42..09d1f6f 100644 --- a/tests/InfoAttributeRecordsTest.php +++ b/tests/InfoAttributeRecordsTest.php @@ -81,7 +81,7 @@ public function testAttributeRecordCountUsesEncodedBytes(): void $buffer->rewind(); self::assertSame(7, $buffer->readInt()); - $parsed = new InfoCollection()->fill($buffer)[DataFileAttributes::SUBTYPE]; + $parsed = (new InfoCollection())->fill($buffer)[DataFileAttributes::SUBTYPE]; self::assertSame($expected, $parsed->toArray()); } @@ -112,8 +112,8 @@ public function testInfoCollectionPreservesAndMergesDuplicateVariableAttributeRe ]; $buffer = Buffer::factory('', ['memory' => true]); - new VariableAttributes(['data' => $firstData])->write($buffer); - new VariableAttributes(['data' => $secondData])->write($buffer); + (new VariableAttributes(['data' => $firstData]))->write($buffer); + (new VariableAttributes(['data' => $secondData]))->write($buffer); $buffer->rewind(); $collection = new InfoCollection(); @@ -149,7 +149,7 @@ public function testMalformedAttributeRecordsHaveClearErrors( $this->expectException(1 === $size ? \UnexpectedValueException::class : Exception::class); $this->expectExceptionMessage($message); - new InfoCollection()->fill($buffer); + (new InfoCollection())->fill($buffer); } /** @return iterable */ @@ -194,7 +194,7 @@ private function roundTrip(DataFileAttributes|VariableAttributes $record): DataF $record->write($buffer); $buffer->rewind(); self::assertSame(7, $buffer->readInt()); - $parsed = new InfoCollection()->fill($buffer)[$record::SUBTYPE]; + $parsed = (new InfoCollection())->fill($buffer)[$record::SUBTYPE]; if (!$parsed instanceof DataFileAttributes && !$parsed instanceof VariableAttributes) { throw new \LogicException(sprintf('Unexpected record type %s.', $parsed::class)); } diff --git a/tests/InfoSetRecordsTest.php b/tests/InfoSetRecordsTest.php index 33f3f69..efcf46b 100644 --- a/tests/InfoSetRecordsTest.php +++ b/tests/InfoSetRecordsTest.php @@ -163,7 +163,7 @@ public function testMalformedRecordsHaveClearErrors(int $subtype, int $size, str $this->expectException(1 === $size ? \UnexpectedValueException::class : Exception::class); $this->expectExceptionMessage($message); - new InfoCollection()->fill($buffer); + (new InfoCollection())->fill($buffer); } /** @return iterable */ @@ -205,7 +205,7 @@ public function testVariableSetsWriterRejectsAmbiguousIdentifiers(array $data, s { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($message); - new VariableSets(['data' => $data])->write(Buffer::factory('', ['memory' => true])); + (new VariableSets(['data' => $data]))->write(Buffer::factory('', ['memory' => true])); } /** @return iterable>, string}> */ @@ -253,7 +253,7 @@ public function testMultipleResponseSetsWriterRejectsInvalidSemantics( ): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($message); - new MultipleResponseSets(['subtype' => $subtype, 'data' => $data]) + (new MultipleResponseSets(['subtype' => $subtype, 'data' => $data])) ->write(Buffer::factory('', ['memory' => true])); } @@ -264,7 +264,7 @@ private function roundTrip(VariableSets|MultipleResponseSets $record): VariableS $buffer->rewind(); self::assertSame(7, $buffer->readInt()); - $parsed = new InfoCollection()->fill($buffer); + $parsed = (new InfoCollection())->fill($buffer); $subtype = $record instanceof MultipleResponseSets ? $record->subtype : VariableSets::SUBTYPE; $parsedRecord = $parsed[$subtype]; @@ -284,7 +284,7 @@ private function readRecord(int $subtype, string $payload): VariableSets|Multipl $buffer->write($payload); $buffer->rewind(); - $record = new InfoCollection()->fill($buffer)[$subtype]; + $record = (new InfoCollection())->fill($buffer)[$subtype]; if (!$record instanceof VariableSets && !$record instanceof MultipleResponseSets) { throw new \LogicException(sprintf('Unexpected record type %s.', $record::class)); } diff --git a/tests/LongStringValueLabelsValidationTest.php b/tests/LongStringValueLabelsValidationTest.php index 54d6e36..a3400ee 100644 --- a/tests/LongStringValueLabelsValidationTest.php +++ b/tests/LongStringValueLabelsValidationTest.php @@ -32,7 +32,7 @@ public function testLabelsRoundTripPreservesOrderDuplicatesAndFileEncoding(): vo $record->write($buffer); $buffer->rewind(); self::assertSame(7, $buffer->readInt()); - $parsed = new InfoCollection()->fill($buffer)[LongStringValueLabels::SUBTYPE]; + $parsed = (new InfoCollection())->fill($buffer)[LongStringValueLabels::SUBTYPE]; self::assertSame( [ @@ -66,7 +66,7 @@ public function testSingleByteFileEncodingUsesTargetByteLengthsAtEveryBoundary() $record->write($buffer); $buffer->rewind(); self::assertSame(7, $buffer->readInt()); - $parsed = new InfoCollection()->fill($buffer)[LongStringValueLabels::SUBTYPE]; + $parsed = (new InfoCollection())->fill($buffer)[LongStringValueLabels::SUBTYPE]; self::assertSame( [ @@ -83,7 +83,7 @@ public function testSingleByteFileEncodingUsesTargetByteLengthsAtEveryBoundary() public function testEmptyLabelRecordEmitsNoInfoRecord(): void { $buffer = Buffer::factory('', ['memory' => true]); - new LongStringValueLabels()->write($buffer); + (new LongStringValueLabels())->write($buffer); self::assertSame(0, $buffer->position()); self::assertSame('', $buffer->read()); @@ -159,6 +159,6 @@ public function testWriterRejectsMalformedData(array $data, string $message): vo { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($message); - new LongStringValueLabels(['data' => $data])->write(Buffer::factory('', ['memory' => true])); + (new LongStringValueLabels(['data' => $data]))->write(Buffer::factory('', ['memory' => true])); } } diff --git a/tests/MalformedContainerTest.php b/tests/MalformedContainerTest.php index 6f78953..e83df3b 100644 --- a/tests/MalformedContainerTest.php +++ b/tests/MalformedContainerTest.php @@ -102,7 +102,7 @@ private function savBytes(int $compression): string private function container(string $body): string { $buffer = Buffer::factory('', ['memory' => true]); - new Header()->write($buffer); + (new Header())->write($buffer); $buffer->write($body); $buffer->rewind(); diff --git a/tests/RecordCountValidationTest.php b/tests/RecordCountValidationTest.php index 7e52c6a..3a54bb5 100644 --- a/tests/RecordCountValidationTest.php +++ b/tests/RecordCountValidationTest.php @@ -143,7 +143,7 @@ public function testInvalidInfoSizeCountsAreRejected(int $size, int $count, stri $this->expectException(Exception::class); $this->expectExceptionMessage($message); - new InfoCollection()->fill($buffer); + (new InfoCollection())->fill($buffer); } /** @return iterable */ @@ -165,6 +165,6 @@ public function testTruncatedInfoCountIsRejected(): void $this->expectException(Exception::class); $this->expectExceptionMessage('truncated size or count'); - new InfoCollection()->fill($buffer); + (new InfoCollection())->fill($buffer); } } diff --git a/tools/run-branch-coverage.php b/tools/run-branch-coverage.php index e415786..08b84f0 100644 --- a/tools/run-branch-coverage.php +++ b/tools/run-branch-coverage.php @@ -71,7 +71,7 @@ public function run(): int $this->runShard($shard, $tests); } - $merged = new Merger()->merge($this->shardArtifacts, false); + $merged = (new Merger())->merge($this->shardArtifacts, false); $report = ReportFacade::fromSerializedData($merged); $report->renderCobertura($this->reportPath); $summary = $report->summary(); @@ -222,4 +222,4 @@ private function removeArtifacts(array $paths): void } } -exit(new BranchCoverageRunner()->run()); +exit((new BranchCoverageRunner())->run()); From 8c56a370c125e8468f40257b939821e0fd02f5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B5nis=20Ormisson?= Date: Mon, 9 Nov 2026 18:32:19 +0200 Subject: [PATCH 2/2] ci: keep PHPUnit 13 for tooling, use PHPUnit 12.5 only in the PHP 8.3 job --- .github/workflows/qa.yml | 1 + CHANGELOG.md | 2 +- composer.json | 2 +- composer.lock | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index e5b2fbb..646136d 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -65,6 +65,7 @@ jobs: - name: Install lowest supported dependencies for PHP 8.3 run: | composer config platform.php 8.3.0 + composer require --dev --no-update "phpunit/phpunit:^12.5" composer update --no-interaction --prefer-dist --prefer-lowest - name: Run syntax and unit tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb4c49..3a5fee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 3.2.0 (2026-11-09) - Restored PHP 8.3 support. The minimum had been raised to 8.4.1 with the v3 tooling update, but the library only used three PHP 8.4 constructs (`new` without parentheses in two places and `array_all()`); these are replaced with PHP 8.3 equivalents without behaviour changes. -- PHPUnit 12.5 is accepted for PHP 8.3; CI adds a PHP 8.3 lowest-dependency job that runs syntax checks and the full test suite. Static analysis and coverage/mutation gates continue on PHP 8.4+. +- CI adds a PHP 8.3 lowest-dependency job that runs syntax checks and the full test suite with PHPUnit 12.5. Development tooling, static analysis and coverage/mutation gates stay on PHP 8.4+ with PHPUnit 13. - No runtime API or SAV/ZSAV format changes. ## 3.1.2 (2026-11-09) diff --git a/composer.json b/composer.json index fedd69a..ab6082c 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "phpstan/phpstan": "^2.2", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^12.5 || ^13.2", + "phpunit/phpunit": "^13.2", "rector/rector": "^2.5" }, "autoload": { diff --git a/composer.lock b/composer.lock index 0dde93f..669fed0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cec70b92849a9de25f60ac1092210549", + "content-hash": "ed27559dba21cf1b133159122931bc18", "packages": [], "packages-dev": [ {