Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

## [0.8.1] - 2026-11-09

### Changed

- Make the shared import preflight cheaper per cell (same rules, order and
messages) and bind SQLite case rows with positional placeholders. A 50,000
case by 201 variable SAV imports into SQLite in 12-13 s instead of 19-20 s.

## [0.8.0] - 2026-09-10

This release consolidates the never-tagged, never-published 0.7.2 preparation
Expand Down Expand Up @@ -179,7 +187,8 @@ ownership checks are retained; this release requires no new migration.
- Pinned active conformance fixtures and capabilities to released OpenStatSpec
specification v0.1.0 at commit `d287c2cde9ade71f04e27dd012caec876901aed5`.

[Unreleased]: https://github.com/OpenStatSpec/php/compare/v0.8.0...HEAD
[Unreleased]: https://github.com/OpenStatSpec/php/compare/v0.8.1...HEAD
[0.8.1]: https://github.com/OpenStatSpec/php/compare/v0.8.0...v0.8.1
[0.8.0]: https://github.com/OpenStatSpec/php/compare/v0.7.1...v0.8.0
[0.7.1]: https://github.com/OpenStatSpec/php/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/OpenStatSpec/php/compare/v0.6.0...v0.7.0
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ It imports an unencrypted SPSS `.sav` or `.zsav` dataset into a relational datab

## Status

PHP v0.8.0 targets released OpenStatSpec specification `v0.5.0` at immutable
PHP v0.8.1 targets released OpenStatSpec specification `v0.5.0` at immutable
commit `864e84479f554b8ee250ffed44c4dfb963750d4a`
(`specification_status: released`). Composer derives the package version from its
Git tag; it is independent of the specification version.
Expand Down
57 changes: 33 additions & 24 deletions src/Sql/AbstractPdoSqlProfile.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public function assertCanRepresent(int $sourceVariableCount): void

/**
* @param list<array<string, mixed>> $variables
* @param list<list<int|float|string|null>> $rows
* @param list<array<mixed>> $rows Untrusted cells; this is the type gate.
*/
public function assertDataset(array $variables, array $rows, ?PDO $pdo = null): void
{
Expand Down Expand Up @@ -93,37 +93,38 @@ public function assertDataset(array $variables, array $rows, ?PDO $pdo = null):
throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'Every source case must contain exactly one value per source variable.');
}
$encodedRowBytes = 0;
// Branch on the value type first: a numeric cell costs two type
// tests and one finiteness test. Rules, order and messages match
// the previous per-cell checks for list rows.
foreach ($row as $index => $value) {
$kind = $storageKinds[$index];
if ($kind === 'string' && !is_string($value)) {
throw new UnsupportedOperation(
DiagnosticCode::InvalidSourceDataset,
'SPSS string values must be non-null strings.',
);
if (is_string($value)) {
if ($kind === 'numeric') {
$this->invalidNumericValue();
}
if (strlen($value) > $maximumValueBytes) {
$this->capabilityExceeded('encoded string value', strlen($value), $maximumValueBytes);
}
$encodedRowBytes += strlen($value);
continue;
}
if ($kind === 'numeric'
&& $value !== null
&& !is_int($value)
&& !is_float($value)
) {
if ($kind === 'string') {
throw new UnsupportedOperation(
DiagnosticCode::InvalidSourceDataset,
'SPSS numeric values must be binary64 numbers or system-missing NULL.',
);
}
if ($kind === 'numeric'
&& (is_int($value) || is_float($value))
&& !is_finite((float) $value)
) {
throw new UnsupportedOperation(
DiagnosticCode::TargetCapabilityExceeded,
$this->driverName() . ' rejects non-finite SPSS numeric values before mutation.',
'SPSS string values must be non-null strings.',
);
}
if (is_string($value) && strlen($value) > $maximumValueBytes) {
$this->capabilityExceeded('encoded string value', strlen($value), $maximumValueBytes);
if (is_float($value)) {
if (!is_finite($value)) {
throw new UnsupportedOperation(
DiagnosticCode::TargetCapabilityExceeded,
$this->driverName() . ' rejects non-finite SPSS numeric values before mutation.',
);
}
} elseif ($value !== null && !is_int($value)) {
$this->invalidNumericValue();
}
$encodedRowBytes += is_string($value) ? strlen($value) : 8;
$encodedRowBytes += 8;
}
if ($encodedRowBytes > $maximumEncodedCaseBytes) {
$this->capabilityExceeded('encoded case payload', $encodedRowBytes, $maximumEncodedCaseBytes);
Expand Down Expand Up @@ -167,6 +168,14 @@ protected function rowStorageBytes(string $kind, int $declaredWidth): int
return $kind === 'string' ? $declaredWidth : 8;
}

private function invalidNumericValue(): never
{
throw new UnsupportedOperation(
DiagnosticCode::InvalidSourceDataset,
'SPSS numeric values must be binary64 numbers or system-missing NULL.',
);
}

private function capabilityExceeded(string $limit, int $actual, int $maximum): never
{
throw new UnsupportedOperation(
Expand Down
8 changes: 4 additions & 4 deletions src/Sql/SqliteWideTableImporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -302,16 +302,16 @@ private function insertCases(string $tableName, array $variables, array $rows):
{
$columns = array_merge(['__case_ordinal'], array_column($variables, 'column'));
$quoted = array_map(fn(string $column): string => $this->quote($column), $columns);
$params = array_map(static fn(string $column): string => ':' . $column, $columns);
$statement = $this->pdo->prepare('INSERT INTO ' . $this->quote($tableName) . ' (' . implode(', ', $quoted) . ') VALUES (' . implode(', ', $params) . ')');
// Positional placeholders: named ones cost a name lookup per bound cell.
$statement = $this->pdo->prepare('INSERT INTO ' . $this->quote($tableName) . ' (' . implode(', ', $quoted) . ') VALUES (' . implode(', ', array_fill(0, count($columns), '?')) . ')');
foreach ($rows as $caseOrdinal => $row) {
$values = ['__case_ordinal' => $caseOrdinal + 1];
$values = [$caseOrdinal + 1];
foreach ($variables as $position => $variable) {
$value = is_array($row) ? ($row[$variable['source']] ?? $row[$position] ?? null) : null;
if ($value === null && $variable['kind'] === 'string') {
throw new UnsupportedOperation(DiagnosticCode::InvalidSourceDataset, 'SPSS string values must not be represented as NULL.');
}
$values[$variable['column']] = is_float($value) ? Binary64::encode($value) : $value;
$values[] = is_float($value) ? Binary64::encode($value) : $value;
}
$statement->execute($values);
}
Expand Down
32 changes: 32 additions & 0 deletions tests/Sql/PdoSqlProfileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OpenStatSpec\Sql\OperationJournal;
use PDO;
use PDOStatement;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class PdoSqlProfileTest extends TestCase
Expand Down Expand Up @@ -122,6 +123,37 @@ public function testMySqlPreflightRejectsCombinedCasePayloadBeyondActivePacket()
);
}

/** @return iterable<string, array{list<list<mixed>>, DiagnosticCode, string}> */
public static function invalidCases(): iterable
{
yield 'bool in numeric column' => [[[true, 'a']], DiagnosticCode::InvalidSourceDataset, 'binary64 numbers or system-missing NULL'];
yield 'string in numeric column' => [[['1', 'a']], DiagnosticCode::InvalidSourceDataset, 'binary64 numbers or system-missing NULL'];
yield 'infinity' => [[[INF, 'a']], DiagnosticCode::TargetCapabilityExceeded, 'rejects non-finite'];
yield 'nan' => [[[NAN, 'a']], DiagnosticCode::TargetCapabilityExceeded, 'rejects non-finite'];
yield 'null string' => [[[1.0, null]], DiagnosticCode::InvalidSourceDataset, 'non-null strings'];
yield 'int in string column' => [[[1.0, 5]], DiagnosticCode::InvalidSourceDataset, 'non-null strings'];
yield 'case payload' => [[[1.0, str_repeat('a', 65_528)]], DiagnosticCode::TargetCapabilityExceeded, 'encoded case payload is 65536 bytes'];
yield 'first cell wins within a case' => [[[true, null]], DiagnosticCode::InvalidSourceDataset, 'binary64 numbers'];
yield 'first case wins' => [[[1.0, null], [INF, 'a']], DiagnosticCode::InvalidSourceDataset, 'non-null strings'];
yield 'cell rule before payload' => [[[NAN, str_repeat('a', 65_528)]], DiagnosticCode::TargetCapabilityExceeded, 'rejects non-finite'];
}

/** @param list<list<mixed>> $rows */
#[DataProvider('invalidCases')]
public function testPreflightRejectsInvalidCellsInSourceOrder(array $rows, DiagnosticCode $code, string $message): void
{
$variables = [['name' => 'n', 'type' => 'numeric'], ['name' => 's', 'type' => 'string', 'width' => 8]];
(new MySqlProfile())->assertDataset($variables, [[null, ''], [1, 'a'], [-1.5, str_repeat('b', 65_527)]]);

try {
(new MySqlProfile())->assertDataset($variables, $rows);
self::fail('Preflight accepted an invalid case.');
} catch (UnsupportedOperation $exception) {
self::assertSame($code, $exception->diagnosticCode);
self::assertStringContainsString($message, $exception->getMessage());
}
}

public function testPostgreSqlMapsLongCollidingSourceNamesWithinIdentifierLimit(): void
{
$profile = new PostgreSqlProfile();
Expand Down
Loading