From cd2827b74a2951e60e6a59846e7134d5c4b2150e Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 28 Sep 2026 12:46:53 +1000 Subject: [PATCH 1/4] Close 68 escaped mutants across metadata, SQL and table gateway Escaped mutants drop from 79 to 11 and Covered MSI rises from 97% to 99%. The largest cluster was one root cause: AbstractSourceTest seeds the data property by reflection before calling a getter, so the getter's own loader call is unobservable and Infection deletes it for free. LazyRecordingSource seeds only inside loadXData(), delegates to its parent (which keeps the parent's protected visibility observable from a subclass) and records each call, so dropping a loader call from a getter now empties the result. That plus two genuine gaps in the same file - constraint keys matching on only half the table/constraint pair, and the hierarchy builder auto-vivifying nulls - accounts for 33 of them. Elsewhere: Values and Identifiers had no test file at all; the predicates round-tripped setSpecification() without checking getExpressionData() honoured it; the length columns never covered a value long enough for the array_splice insert to displace anything; and Integer's display width, new in #185, never saw a zero width or a values array long enough to notice a removal. Twelve mutants are verified equivalents rather than gaps and are ignored in infection.json5.dist with reasons: the six loader guards short-circuit an idempotent prepareDataHierarchy(), getColumns() delegates to getColumnNames() which reloads the same data, implode() discards the keys array_fill() sets, a RowGatewayInterface is caught by the next guard anyway, and stopPropagation() has an empty body. The 11 remaining escapes are all exception-message concatenation. Killing them means pinning exact wording, which buys brittleness rather than safety, so they are left visible rather than suppressed. --- infection.json5.dist | 46 ++- test/unit/ConfigProviderTest.php | 7 + .../unit/Exception/ContainerExceptionTest.php | 10 + .../unit/Metadata/Object/ColumnObjectTest.php | 18 + .../Source/AbstractSourceLazyLoadTest.php | 346 ++++++++++++++++++ .../Source/TestAsset/LazyRecordingSource.php | 262 +++++++++++++ .../RowGateway/Feature/FeatureSetTest.php | 15 + test/unit/Sql/Argument/IdentifiersTest.php | 71 ++++ test/unit/Sql/Argument/ValuesTest.php | 71 ++++ .../Ddl/Column/AbstractLengthColumnTest.php | 48 +++ test/unit/Sql/Ddl/Column/IntegerTest.php | 35 ++ test/unit/Sql/Predicate/BetweenTest.php | 14 + test/unit/Sql/Predicate/IsNullTest.php | 14 + test/unit/Sql/Predicate/LikeTest.php | 14 + test/unit/Sql/Predicate/OperatorTest.php | 14 + .../TableGateway/Feature/FeatureSetTest.php | 66 ++++ .../Feature/MetadataFeatureTest.php | 80 ++++ .../Feature/RowGatewayFeatureTest.php | 7 +- .../Feature/SequenceFeatureTest.php | 47 ++- .../TestAsset/TestTableGatewayFeature.php | 6 + 20 files changed, 1185 insertions(+), 6 deletions(-) create mode 100644 test/unit/Metadata/Source/AbstractSourceLazyLoadTest.php create mode 100644 test/unit/Metadata/Source/TestAsset/LazyRecordingSource.php create mode 100644 test/unit/Sql/Argument/IdentifiersTest.php create mode 100644 test/unit/Sql/Argument/ValuesTest.php diff --git a/infection.json5.dist b/infection.json5.dist index 71b82aa6..a91839b3 100644 --- a/infection.json5.dist +++ b/infection.json5.dist @@ -16,7 +16,51 @@ } }, "mutators": { - "@default": true + "@default": true, + "DecrementInteger": { + "ignore": [ + // implode() discards keys, so array_fill()'s start index cannot be observed. + "PhpDb\\Sql\\Argument\\Identifiers::getSpecification", + "PhpDb\\Sql\\Argument\\Values::getSpecification", + "PhpDb\\Sql\\Ddl\\Constraint\\ForeignKey::getExpressionData" + ] + }, + "IncrementInteger": { + "ignore": [ + // implode() discards keys, so array_fill()'s start index cannot be observed. + "PhpDb\\Sql\\Argument\\Identifiers::getSpecification", + "PhpDb\\Sql\\Argument\\Values::getSpecification", + "PhpDb\\Sql\\Ddl\\Constraint\\ForeignKey::getExpressionData" + ] + }, + "MethodCallRemoval": { + "ignore": [ + // Delegates to getColumnNames(), which loads the same data itself. + "PhpDb\\Metadata\\Source\\AbstractSource::getColumns" + ] + }, + "ReturnRemoval": { + "ignore": [ + // The guard short-circuits prepareDataHierarchy(), which is itself + // idempotent, so falling through to it has no observable effect. + "PhpDb\\Metadata\\Source\\AbstractSource::loadColumnData", + "PhpDb\\Metadata\\Source\\AbstractSource::loadConstraintData", + "PhpDb\\Metadata\\Source\\AbstractSource::loadConstraintDataKeys", + "PhpDb\\Metadata\\Source\\AbstractSource::loadConstraintReferences", + "PhpDb\\Metadata\\Source\\AbstractSource::loadTableNameData", + "PhpDb\\Metadata\\Source\\AbstractSource::loadTriggerData", + // A RowGatewayInterface is neither null nor a string, so the next guard + // returns for exactly the inputs this one does. + "PhpDb\\TableGateway\\Feature\\RowGatewayFeature::postInitialize" + ] + }, + "TrueValue": { + "ignore": [ + // The method body is empty; the flag is accepted for interface + // compatibility and never read. + "PhpDb\\TableGateway\\Feature\\EventFeature\\TableGatewayEvent::stopPropagation" + ] + } }, "staticAnalysisTool": "mago" } diff --git a/test/unit/ConfigProviderTest.php b/test/unit/ConfigProviderTest.php index 085f05ee..8d3b7eb3 100644 --- a/test/unit/ConfigProviderTest.php +++ b/test/unit/ConfigProviderTest.php @@ -10,6 +10,7 @@ use PhpDb\Sql; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; #[CoversClass(ConfigProvider::class)] @@ -40,6 +41,12 @@ class ConfigProviderTest extends TestCase ], ]; + #[Test] + public function getDependenciesIsCallableAsPartOfThePublicApi(): void + { + static::assertEquals($this->config['dependencies'], (new ConfigProvider())->getDependencies()); + } + public function testInvocationProvidesDependencyConfiguration(): void { self::assertEquals($this->config, (new ConfigProvider())()); diff --git a/test/unit/Exception/ContainerExceptionTest.php b/test/unit/Exception/ContainerExceptionTest.php index 5356edcc..32042124 100644 --- a/test/unit/Exception/ContainerExceptionTest.php +++ b/test/unit/Exception/ContainerExceptionTest.php @@ -7,6 +7,7 @@ use PhpDb\Exception\ContainerException; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerExceptionInterface; @@ -14,6 +15,15 @@ #[CoversMethod(ContainerException::class, 'forService')] final class ContainerExceptionTest extends TestCase { + /** + * The factory pins the code to zero; callers distinguish these by type, not by code. + */ + #[Test] + public function forServiceCreatesExceptionWithoutACode(): void + { + static::assertSame(0, ContainerException::forService('Svc', 'Factory', 'reason')->getCode()); + } + public function testForServiceCreatesFormattedExceptionMessage(): void { $exception = ContainerException::forService('Svc', 'Factory', 'reason'); diff --git a/test/unit/Metadata/Object/ColumnObjectTest.php b/test/unit/Metadata/Object/ColumnObjectTest.php index adb2fbf2..716dcb0e 100644 --- a/test/unit/Metadata/Object/ColumnObjectTest.php +++ b/test/unit/Metadata/Object/ColumnObjectTest.php @@ -207,6 +207,24 @@ public function setErratasIteratesCorrectly(): void static::assertSame('value2', $column->getErrata('key2')); } + /** + * Erratas accumulate: a second call adds to what is already there rather than + * replacing it. + */ + #[Test] + public function setErratasMergesIntoTheExistingErrata(): void + { + $column = new ColumnObject('column', 'table', 'schema'); + + $column->setErratas(['auto_increment' => true]); + $column->setErratas(['comment' => 'Primary key']); + + static::assertSame( + ['auto_increment' => true, 'comment' => 'Primary key'], + $column->getErratas(), + ); + } + #[Test] public function setErratasWithArrayAndGetErratas(): void { diff --git a/test/unit/Metadata/Source/AbstractSourceLazyLoadTest.php b/test/unit/Metadata/Source/AbstractSourceLazyLoadTest.php new file mode 100644 index 00000000..ff532848 --- /dev/null +++ b/test/unit/Metadata/Source/AbstractSourceLazyLoadTest.php @@ -0,0 +1,346 @@ +source->getColumn('id', LazyRecordingSource::BASE_TABLE, LazyRecordingSource::SCHEMA); + + static::assertSame('id', $column->getName()); + } + + /** + * @throws Exception + */ + #[Test] + public function getColumnNamesLoadsColumnDataOnDemand(): void + { + $names = $this->source->getColumnNames(LazyRecordingSource::BASE_TABLE, LazyRecordingSource::SCHEMA); + + static::assertSame(['id', 'email'], $names); + } + + /** + * Every attribute the loader supplies must reach the ColumnObject, not just the ones + * a narrower assertion happens to look at. + * + * @throws Exception + */ + #[Test] + public function getColumnTransfersEveryAttributeFromTheLoadedRow(): void + { + $column = $this->source->getColumn('id', LazyRecordingSource::BASE_TABLE, LazyRecordingSource::SCHEMA); + + static::assertSame( + [ + 'ordinal_position' => 1, + 'column_default' => null, + 'is_nullable' => false, + 'data_type' => 'INT', + 'character_maximum_length' => null, + 'character_octet_length' => null, + 'numeric_precision' => 10, + 'numeric_scale' => 2, + 'numeric_unsigned' => true, + 'erratas' => ['storage' => 'inline'], + ], + [ + 'ordinal_position' => $column->getOrdinalPosition(), + 'column_default' => $column->getColumnDefault(), + 'is_nullable' => $column->getIsNullable(), + 'data_type' => $column->getDataType(), + 'character_maximum_length' => $column->getCharacterMaximumLength(), + 'character_octet_length' => $column->getCharacterOctetLength(), + 'numeric_precision' => $column->getNumericPrecision(), + 'numeric_scale' => $column->getNumericScale(), + 'numeric_unsigned' => $column->getNumericUnsigned(), + 'erratas' => $column->getErratas(), + ], + ); + } + + /** + * @throws Exception + */ + #[Test] + public function getConstraintKeysLoadsConstraintKeyDataOnDemand(): void + { + $keys = $this->source->getConstraintKeys( + LazyRecordingSource::PRIMARY_KEY, + LazyRecordingSource::BASE_TABLE, + LazyRecordingSource::SCHEMA, + ); + + static::assertCount(1, $keys); + } + + /** + * @throws Exception + */ + #[Test] + public function getConstraintKeysLoadsConstraintReferencesOnDemand(): void + { + $keys = $this->source->getConstraintKeys( + LazyRecordingSource::PRIMARY_KEY, + LazyRecordingSource::BASE_TABLE, + LazyRecordingSource::SCHEMA, + ); + + static::assertSame('accounts', $keys[0]->getReferencedTableName()); + } + + /** + * The fixture holds one key matching only the table name and one matching only the + * constraint name, so accepting either in place of both is observable. + * + * @throws Exception + */ + #[Test] + public function getConstraintKeysRequiresBothTableAndConstraintToMatch(): void + { + $keys = $this->source->getConstraintKeys( + LazyRecordingSource::PRIMARY_KEY, + LazyRecordingSource::BASE_TABLE, + LazyRecordingSource::SCHEMA, + ); + + static::assertSame(['id'], array_map(static fn($key): string => $key->getColumnName(), $keys)); + } + + /** + * @throws Exception + */ + #[Test] + public function getConstraintLoadsConstraintDataOnDemand(): void + { + $constraint = $this->source->getConstraint( + LazyRecordingSource::PRIMARY_KEY, + LazyRecordingSource::BASE_TABLE, + LazyRecordingSource::SCHEMA, + ); + + static::assertSame('PRIMARY KEY', $constraint->getType()); + } + + /** + * @throws Exception + */ + #[Test] + public function getConstraintsLoadsConstraintDataOnDemand(): void + { + $constraints = $this->source->getConstraints( + LazyRecordingSource::BASE_TABLE, + LazyRecordingSource::SCHEMA, + ); + + static::assertSame( + [LazyRecordingSource::PRIMARY_KEY, LazyRecordingSource::UNIQUE_KEY], + array_map(static fn($constraint): string => $constraint->getName(), $constraints), + ); + } + + /** + * @throws Exception + */ + #[Test] + public function getTableLoadsTableNameDataOnDemand(): void + { + $table = $this->source->getTable(LazyRecordingSource::BASE_TABLE, LazyRecordingSource::SCHEMA); + + static::assertInstanceOf(TableObject::class, $table); + } + + /** + * @throws Exception + */ + #[Test] + public function getTableNamesLoadsTableNameDataOnDemand(): void + { + $names = $this->source->getTableNames(LazyRecordingSource::SCHEMA, true); + + static::assertSame([LazyRecordingSource::BASE_TABLE, LazyRecordingSource::VIEW], $names); + } + + /** + * @throws Exception + */ + #[Test] + public function getTablePopulatesColumnsOnTheReturnedTable(): void + { + $table = $this->source->getTable(LazyRecordingSource::BASE_TABLE, LazyRecordingSource::SCHEMA); + + static::assertCount(2, $table->getColumns()); + } + + /** + * @throws Exception + */ + #[Test] + public function getTablePopulatesConstraintsOnTheReturnedTable(): void + { + $table = $this->source->getTable(LazyRecordingSource::BASE_TABLE, LazyRecordingSource::SCHEMA); + + static::assertCount(2, $table->getConstraints()); + } + + /** + * @throws Exception + */ + #[Test] + public function getTablesExcludesViewsUnlessAsked(): void + { + $tables = $this->source->getTables(LazyRecordingSource::SCHEMA); + + static::assertSame( + [LazyRecordingSource::BASE_TABLE], + array_map(static fn($table): string => $table->getName(), $tables), + ); + } + + /** + * @throws Exception + */ + #[Test] + public function getTriggerLoadsTriggerDataOnDemand(): void + { + $trigger = $this->source->getTrigger(LazyRecordingSource::TRIGGER, LazyRecordingSource::SCHEMA); + + static::assertSame(LazyRecordingSource::TRIGGER, $trigger->getName()); + } + + /** + * @throws Exception + */ + #[Test] + public function getTriggerNamesLoadsTriggerDataOnDemand(): void + { + $names = $this->source->getTriggerNames(LazyRecordingSource::SCHEMA); + + static::assertSame([LazyRecordingSource::TRIGGER], $names); + } + + /** + * @throws Exception + */ + #[Test] + public function getTriggerTransfersTheCreatedTimestamp(): void + { + $trigger = $this->source->getTrigger(LazyRecordingSource::TRIGGER, LazyRecordingSource::SCHEMA); + + static::assertEquals(new DateTime('2026-01-01 00:00:00'), $trigger->getCreated()); + } + + /** + * @throws Exception + */ + #[Test] + public function getViewLoadsTableNameDataOnDemand(): void + { + $view = $this->source->getView(LazyRecordingSource::VIEW, LazyRecordingSource::SCHEMA); + + static::assertInstanceOf(ViewObject::class, $view); + } + + /** + * @throws Exception + */ + #[Test] + public function getViewNamesLoadsTableNameDataOnDemand(): void + { + $names = $this->source->getViewNames(LazyRecordingSource::SCHEMA); + + static::assertSame([LazyRecordingSource::VIEW], $names); + } + + /** + * @throws Exception + */ + #[Test] + public function loadersRunOnceAcrossRepeatedReads(): void + { + $this->source->getColumnNames(LazyRecordingSource::BASE_TABLE, LazyRecordingSource::SCHEMA); + $this->source->getColumnNames(LazyRecordingSource::BASE_TABLE, LazyRecordingSource::SCHEMA); + $this->source->getColumn('id', LazyRecordingSource::BASE_TABLE, LazyRecordingSource::SCHEMA); + + static::assertSame(1, $this->source->loadCount('loadColumnData')); + } + + /** + * assertArrayHasKey passes for a null value, so the hierarchy is compared whole: + * every level must be an array, not an auto-vivified null. + */ + #[Test] + public function prepareDataHierarchyCreatesAnArrayAtEveryLevel(): void + { + $this->source->prepareHierarchy('level1', 'level2', 'level3'); + + static::assertSame(['level1' => ['level2' => ['level3' => []]]], $this->source->seededData()); + } + + #[Override] + protected function setUp(): void + { + $adapter = $this->createMockForIntersectionOfInterfaces([ + AdapterInterface::class, + SchemaAwareInterface::class, + ]); + $adapter->method('getCurrentSchema')->willReturn(LazyRecordingSource::SCHEMA); + + $this->source = new LazyRecordingSource($adapter); + } +} diff --git a/test/unit/Metadata/Source/TestAsset/LazyRecordingSource.php b/test/unit/Metadata/Source/TestAsset/LazyRecordingSource.php new file mode 100644 index 00000000..89ba26dd --- /dev/null +++ b/test/unit/Metadata/Source/TestAsset/LazyRecordingSource.php @@ -0,0 +1,262 @@ +data only when the matching loader runs. + * + * Tests that pre-seed $this->data by reflection cannot observe whether a getter still + * calls its loader, because the data is already there. Loading on demand makes the call + * load-bearing: drop it from the getter and the data is never there at all. + * + * Each loader delegates to its parent before seeding, which keeps the parent's protected + * visibility observable from a subclass, and records the call so the caching contract can + * be asserted directly. + */ +final class LazyRecordingSource extends AbstractSource +{ + public const string SCHEMA = 'public'; + + public const string BASE_TABLE = 'users'; + + public const string VIEW = 'active_users'; + + public const string TRIGGER = 'users_audit'; + + public const string PRIMARY_KEY = 'pk_users'; + + public const string UNIQUE_KEY = 'uq_users_email'; + + /** @var array>> */ + private const array COLUMNS = [ + self::BASE_TABLE => [ + 'id' => [ + 'ordinal_position' => '1', + 'column_default' => null, + 'is_nullable' => false, + 'data_type' => 'INT', + 'character_maximum_length' => null, + 'character_octet_length' => null, + 'numeric_precision' => '10', + 'numeric_scale' => '2', + 'numeric_unsigned' => true, + 'erratas' => ['storage' => 'inline'], + ], + 'email' => [ + 'ordinal_position' => '2', + 'column_default' => null, + 'is_nullable' => true, + 'data_type' => 'VARCHAR', + 'character_maximum_length' => '255', + 'character_octet_length' => '1020', + 'numeric_precision' => null, + 'numeric_scale' => null, + 'numeric_unsigned' => null, + 'erratas' => [], + ], + ], + self::VIEW => [ + 'id' => [ + 'ordinal_position' => '1', + 'column_default' => null, + 'is_nullable' => false, + 'data_type' => 'INT', + 'character_maximum_length' => null, + 'character_octet_length' => null, + 'numeric_precision' => null, + 'numeric_scale' => null, + 'numeric_unsigned' => null, + 'erratas' => [], + ], + ], + ]; + + /** @var array> */ + private const array CONSTRAINTS = [ + self::BASE_TABLE => [ + self::PRIMARY_KEY => [ + 'constraint_type' => 'PRIMARY KEY', + 'columns' => ['id'], + ], + self::UNIQUE_KEY => [ + 'constraint_type' => 'UNIQUE', + 'columns' => ['email'], + ], + ], + self::VIEW => [], + ]; + + /** + * The first and last entries each match exactly one half of the table/constraint pair, + * so a scan that accepts either half in place of both is observable. The single match + * is preceded by a miss, so skipping a miss must not end the scan either. + * + * @var list> + */ + private const array CONSTRAINT_KEYS = [ + [ + 'table_name' => 'audit_log', + 'constraint_name' => self::PRIMARY_KEY, + 'column_name' => 'audit_id', + 'ordinal_position' => 1, + ], + [ + 'table_name' => self::BASE_TABLE, + 'constraint_name' => self::PRIMARY_KEY, + 'column_name' => 'id', + 'ordinal_position' => 1, + ], + [ + 'table_name' => self::BASE_TABLE, + 'constraint_name' => self::UNIQUE_KEY, + 'column_name' => 'email', + 'ordinal_position' => 1, + ], + ]; + + /** @var list> */ + private const array CONSTRAINT_REFERENCES = [ + [ + 'constraint_name' => self::PRIMARY_KEY, + 'update_rule' => 'CASCADE', + 'delete_rule' => 'RESTRICT', + 'referenced_table_name' => 'accounts', + 'referenced_column_name' => 'account_id', + ], + ]; + + /** @var array> */ + private const array TABLE_NAMES = [ + self::BASE_TABLE => ['table_type' => 'BASE TABLE'], + self::VIEW => [ + 'table_type' => 'VIEW', + 'view_definition' => 'SELECT id FROM users', + 'check_option' => 'CASCADED', + 'is_updatable' => false, + ], + ]; + + /** @var array */ + private array $loadCounts = []; + + public function loadCount(string $loader): int + { + return $this->loadCounts[$loader] ?? 0; + } + + /** + * Exposes the protected hierarchy builder so its output shape can be asserted. + */ + public function prepareHierarchy(string $type, string ...$keys): void + { + $this->prepareDataHierarchy($type, ...$keys); + } + + /** + * @return array + */ + public function seededData(): array + { + return $this->data; + } + + #[Override] + protected function loadColumnData(string $table, string $schema): void + { + parent::loadColumnData($table, $schema); + + $this->seed('loadColumnData', $this->data['columns'][$schema][$table], self::COLUMNS[$table] ?? []); + } + + #[Override] + protected function loadConstraintData(string $table, string $schema): void + { + parent::loadConstraintData($table, $schema); + + $this->seed('loadConstraintData', $this->data['constraints'][$schema], self::CONSTRAINTS); + } + + #[Override] + protected function loadConstraintDataKeys(string $schema): void + { + parent::loadConstraintDataKeys($schema); + + $this->seed('loadConstraintDataKeys', $this->data['constraint_keys'][$schema], self::CONSTRAINT_KEYS); + } + + #[Override] + protected function loadConstraintReferences(string $table, string $schema): void + { + parent::loadConstraintReferences($table, $schema); + + $this->seed( + 'loadConstraintReferences', + $this->data['constraint_references'][$schema], + self::CONSTRAINT_REFERENCES, + ); + } + + #[Override] + protected function loadSchemaData(): void + { + $this->seed('loadSchemaData', $this->data['schemas'], [self::SCHEMA, 'reporting']); + } + + #[Override] + protected function loadTableNameData(string $schema): void + { + parent::loadTableNameData($schema); + + $this->seed('loadTableNameData', $this->data['table_names'][$schema], self::TABLE_NAMES); + } + + #[Override] + protected function loadTriggerData(string $schema): void + { + parent::loadTriggerData($schema); + + $this->seed('loadTriggerData', $this->data['triggers'][$schema], [ + self::TRIGGER => [ + 'event_manipulation' => 'INSERT', + 'event_object_catalog' => 'catalog', + 'event_object_schema' => $schema, + 'event_object_table' => self::BASE_TABLE, + 'action_order' => '1', + 'action_condition' => null, + 'action_statement' => 'INSERT INTO audit_log VALUES (NEW.id)', + 'action_orientation' => 'ROW', + 'action_timing' => 'AFTER', + 'action_reference_old_table' => null, + 'action_reference_new_table' => null, + 'action_reference_old_row' => 'OLD', + 'action_reference_new_row' => 'NEW', + 'created' => new DateTime('2026-01-01 00:00:00'), + ], + ]); + } + + /** + * Fills an empty slot once and records the call. A slot that already holds rows is + * left alone, so repeated reads are served from whatever the first load put there. + * + * @param array|null $slot + * @param array $rows + * + * @param-out array $slot + */ + private function seed(string $loader, ?array &$slot, array $rows): void + { + if ([] !== ($slot ?? [])) { + return; + } + + $this->loadCounts[$loader] = $this->loadCount($loader) + 1; + $slot = $rows; + } +} diff --git a/test/unit/RowGateway/Feature/FeatureSetTest.php b/test/unit/RowGateway/Feature/FeatureSetTest.php index 4387224d..1aa6f25a 100644 --- a/test/unit/RowGateway/Feature/FeatureSetTest.php +++ b/test/unit/RowGateway/Feature/FeatureSetTest.php @@ -70,6 +70,21 @@ public function applyCallsMethodOnFeatures(): void static::assertEquals(['arg1', 'arg2'], $feature->receivedArgs); } + /** + * A feature that does not implement the method is skipped, not treated as the end + * of the list. + */ + #[Test] + public function applyContinuesPastFeaturesLackingTheMethod(): void + { + $feature = new TestRowGatewayFeature(); + + $featureSet = new FeatureSet([$this->createMock(AbstractFeature::class), $feature]); + $featureSet->apply('preInitialize', []); + + static::assertTrue($feature->called); + } + #[Test] public function applyHaltsWhenFeatureReturnsHalt(): void { diff --git a/test/unit/Sql/Argument/IdentifiersTest.php b/test/unit/Sql/Argument/IdentifiersTest.php new file mode 100644 index 00000000..8857680f --- /dev/null +++ b/test/unit/Sql/Argument/IdentifiersTest.php @@ -0,0 +1,71 @@ +, 1: string}> + */ + public static function specificationProvider(): array + { + return [ + 'empty set' => [[], '(NULL)'], + 'single column' => [['foo'], '(%s)'], + 'multiple columns' => [['foo', 'bar', 'baz'], '(%s, %s, %s)'], + ]; + } + + /** + * An empty set has no placeholders to render, so it degrades to a literal NULL + * rather than an empty parenthesised list. + * + * @param list $identifiers + */ + #[Test] + #[DataProvider('specificationProvider')] + public function getSpecificationRendersOnePlaceholderPerIdentifier(array $identifiers, string $expected): void + { + static::assertSame($expected, (new Identifiers($identifiers))->getSpecification()); + } + + #[Test] + public function getTypeReturnsIdentifiers(): void + { + static::assertSame(ArgumentType::Identifiers, (new Identifiers(['foo']))->getType()); + } + + #[Test] + public function getValuePreservesOrder(): void + { + static::assertSame(['foo', 'bar'], (new Identifiers(['foo', 'bar']))->getValue()); + } + + /** + * The constructor reindexes, so a set with a gap in it still yields a list whose keys + * line up with the positional placeholders getSpecification emits. + */ + #[Test] + public function getValueReindexesAGappedSet(): void + { + $sparse = ['foo', 'bar', 'baz']; + unset($sparse[1]); + + static::assertSame(['foo', 'baz'], (new Identifiers($sparse))->getValue()); + } +} diff --git a/test/unit/Sql/Argument/ValuesTest.php b/test/unit/Sql/Argument/ValuesTest.php new file mode 100644 index 00000000..35a4ea4e --- /dev/null +++ b/test/unit/Sql/Argument/ValuesTest.php @@ -0,0 +1,71 @@ +, 1: string}> + */ + public static function specificationProvider(): array + { + return [ + 'empty set' => [[], '(NULL)'], + 'single value' => [[1], '(%s)'], + 'three values' => [[1, 2, 3], '(%s, %s, %s)'], + ]; + } + + /** + * An empty set has no placeholders to render, so it degrades to a literal NULL + * rather than an empty parenthesised list. + * + * @param list $values + */ + #[Test] + #[DataProvider('specificationProvider')] + public function getSpecificationRendersOnePlaceholderPerValue(array $values, string $expected): void + { + static::assertSame($expected, (new Values($values))->getSpecification()); + } + + #[Test] + public function getTypeReturnsValues(): void + { + static::assertSame(ArgumentType::Values, (new Values([1, 2]))->getType()); + } + + #[Test] + public function getValuePreservesOrderAndContents(): void + { + static::assertSame([1, 'two', null, false], (new Values([1, 'two', null, false]))->getValue()); + } + + /** + * The constructor reindexes, so a set with a gap in it still yields a list whose keys + * line up with the positional placeholders getSpecification emits. + */ + #[Test] + public function getValueReindexesAGappedSet(): void + { + $sparse = ['a', 'b', 'c']; + unset($sparse[1]); + + static::assertSame(['a', 'c'], (new Values($sparse))->getValue()); + } +} diff --git a/test/unit/Sql/Ddl/Column/AbstractLengthColumnTest.php b/test/unit/Sql/Ddl/Column/AbstractLengthColumnTest.php index 373c48a4..8cc511a2 100644 --- a/test/unit/Sql/Ddl/Column/AbstractLengthColumnTest.php +++ b/test/unit/Sql/Ddl/Column/AbstractLengthColumnTest.php @@ -6,7 +6,9 @@ use PhpDb\Sql\Argument\Identifier; use PhpDb\Sql\Argument\Literal; +use PhpDb\Sql\Argument\Value; use PhpDb\Sql\Ddl\Column\AbstractLengthColumn; +use PhpDb\Sql\Ddl\Constraint\UniqueKey; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; @@ -22,6 +24,36 @@ final class AbstractLengthColumnTest extends TestCase { use ColumnAssertionsTrait; + /** + * The length is inserted directly after the type. Values contributed by a default and + * by a constraint sit after that point, so they must be pushed along rather than + * overwritten. + * + * @throws Exception + */ + #[Test] + public function getExpressionDataInsertsLengthWithoutDisplacingLaterValues(): void + { + $column = $this->getMockBuilder(AbstractLengthColumn::class) + ->setConstructorArgs(['foo', 4, false, 'bar']) + ->onlyMethods([]) + ->getMock(); + $column->addConstraint(new UniqueKey(null, 'uq_foo')); + + $expressionData = $column->getExpressionData(); + + static::assertEquals( + [ + new Identifier('foo'), + new Literal('INTEGER'), + new Literal('4'), + new Value('bar'), + new Identifier('uq_foo'), + ], + $expressionData['values'], + ); + } + /** * @throws Exception */ @@ -73,6 +105,22 @@ public function rendersWithoutParenthesesWhenLengthIsNotSet(): void static::assertColumnRenders('"foo" INTEGER NOT NULL', $column); } + /** + * @throws Exception + */ + #[Test] + public function setLengthWithoutAnArgumentResetsTheLengthToZero(): void + { + $column = $this->getMockBuilder(AbstractLengthColumn::class) + ->setConstructorArgs(['foo', 55]) + ->onlyMethods([]) + ->getMock(); + + $column->setLength(); + + static::assertSame(0, $column->getLength()); + } + /** * @throws Exception */ diff --git a/test/unit/Sql/Ddl/Column/IntegerTest.php b/test/unit/Sql/Ddl/Column/IntegerTest.php index dd26f090..3fa19809 100644 --- a/test/unit/Sql/Ddl/Column/IntegerTest.php +++ b/test/unit/Sql/Ddl/Column/IntegerTest.php @@ -51,6 +51,31 @@ private static function assertColumnRenders(string $expected, ColumnInterface $c ); } + /** + * The display width is inserted directly after the type. Values contributed by a + * default and by a named constraint sit after that point, so they must be pushed + * along rather than overwritten. + */ + #[Test] + public function getExpressionDataInsertsLengthWithoutDisplacingLaterValues(): void + { + $column = new Integer('i', true, 7, ['length' => 11]); + $column->addConstraint(new PrimaryKey(null, 'pk_i')); + + $expressionData = $column->getExpressionData(); + + static::assertEquals( + [ + Argument::identifier('i'), + Argument::literal('INTEGER'), + Argument::literal('11'), + Argument::value(7), + Argument::identifier('pk_i'), + ], + $expressionData['values'], + ); + } + #[Test] public function getExpressionDataPlacesLengthDirectlyAfterType(): void { @@ -67,6 +92,16 @@ public function getExpressionDataPlacesLengthDirectlyAfterType(): void ); } + /** + * Zero is a non-negative integer, so it is a width like any other rather than a + * rejected value. + */ + #[Test] + public function rendersAZeroDisplayWidth(): void + { + static::assertColumnRenders('"i" INTEGER(0) NOT NULL', new Integer('i', false, null, ['length' => 0])); + } + #[Test] public function rendersLengthBeforeNullabilityAndDefault(): void { diff --git a/test/unit/Sql/Predicate/BetweenTest.php b/test/unit/Sql/Predicate/BetweenTest.php index a7e1a508..9acab695 100644 --- a/test/unit/Sql/Predicate/BetweenTest.php +++ b/test/unit/Sql/Predicate/BetweenTest.php @@ -11,6 +11,7 @@ use PhpDb\Sql\ArgumentType; use PhpDb\Sql\Predicate\Between; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; #[CoversMethod(Between::class, '__construct')] @@ -27,6 +28,19 @@ final class BetweenTest extends TestCase { protected Between $between; + /** + * A custom specification replaces the generated one rather than sitting unused + * behind it. + */ + #[Test] + public function getExpressionDataPrefersACustomSpecification(): void + { + $between = new Between('foo.bar', 1, 10); + $between->setSpecification('%1$s IS INBETWEEN %2$s AND %3$s'); + + static::assertSame('%1$s IS INBETWEEN %2$s AND %3$s', $between->getExpressionData()['spec']); + } + public function testConstructorCanPassIdentifierMinimumAndMaximumValues(): void { $between = new Between('foo.bar', 1, 300); diff --git a/test/unit/Sql/Predicate/IsNullTest.php b/test/unit/Sql/Predicate/IsNullTest.php index 2f22c906..6e931186 100644 --- a/test/unit/Sql/Predicate/IsNullTest.php +++ b/test/unit/Sql/Predicate/IsNullTest.php @@ -12,6 +12,7 @@ use PhpDb\Sql\Predicate\IsNull; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; #[CoversMethod(IsNull::class, '__construct')] @@ -23,6 +24,19 @@ #[Group('unit')] final class IsNullTest extends TestCase { + /** + * A custom specification replaces the generated one rather than sitting unused + * behind it. + */ + #[Test] + public function getExpressionDataPrefersACustomSpecification(): void + { + $isNotNull = new IsNotNull('foo.bar'); + $isNotNull->setSpecification('%1$s NOT NULL'); + + static::assertSame('%1$s NOT NULL', $isNotNull->getExpressionData()['spec']); + } + public function testCanPassIdentifierToConstructor(): void { $isnull = new IsNotNull('foo.bar'); diff --git a/test/unit/Sql/Predicate/LikeTest.php b/test/unit/Sql/Predicate/LikeTest.php index b7fbcc22..90405c8d 100644 --- a/test/unit/Sql/Predicate/LikeTest.php +++ b/test/unit/Sql/Predicate/LikeTest.php @@ -10,6 +10,7 @@ use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\Predicate\Like; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; #[CoversMethod(Like::class, '__construct')] @@ -22,6 +23,19 @@ #[CoversMethod(Like::class, 'getExpressionData')] final class LikeTest extends TestCase { + /** + * A custom specification replaces the generated one rather than sitting unused + * behind it. + */ + #[Test] + public function getExpressionDataPrefersACustomSpecification(): void + { + $like = new Like('foo.bar', 'baz%'); + $like->setSpecification('%1$s SOUNDS LIKE %2$s'); + + static::assertSame('%1$s SOUNDS LIKE %2$s', $like->getExpressionData()['spec']); + } + public function testAccessorsMutators(): void { $like = new Like(); diff --git a/test/unit/Sql/Predicate/OperatorTest.php b/test/unit/Sql/Predicate/OperatorTest.php index 77f89657..6051e669 100644 --- a/test/unit/Sql/Predicate/OperatorTest.php +++ b/test/unit/Sql/Predicate/OperatorTest.php @@ -14,6 +14,7 @@ use PhpDb\Sql\Predicate\Operator; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; #[CoversMethod(Operator::class, '__construct')] @@ -27,6 +28,19 @@ #[Group('unit')] final class OperatorTest extends TestCase { + /** + * A custom specification replaces the generated one rather than sitting unused + * behind it. + */ + #[Test] + public function getExpressionDataPrefersACustomSpecification(): void + { + $operator = new Operator('foo.bar', Operator::OPERATOR_EQUAL_TO, 'baz'); + $operator->setSpecification('%1$s IS NOT DISTINCT FROM %2$s'); + + static::assertSame('%1$s IS NOT DISTINCT FROM %2$s', $operator->getExpressionData()['spec']); + } + public function testCanPassAllValuesToConstructor(): void { $operator = new Operator('bar', '>=', 'foo.bar'); diff --git a/test/unit/TableGateway/Feature/FeatureSetTest.php b/test/unit/TableGateway/Feature/FeatureSetTest.php index cd9548f2..be155cec 100644 --- a/test/unit/TableGateway/Feature/FeatureSetTest.php +++ b/test/unit/TableGateway/Feature/FeatureSetTest.php @@ -40,6 +40,25 @@ #[CoversMethod(FeatureSet::class, 'callMagicCall')] class FeatureSetTest extends TestCase { + /** + * A feature added after the table gateway is known receives it on the way in. + */ + #[Test] + public function addFeaturePropagatesAKnownTableGateway(): void + { + $feature = new TestTableGatewayFeature(); + + $tableGateway = $this->getMockBuilder(AbstractTableGateway::class) + ->disableOriginalConstructor() + ->getMock(); + + $featureSet = new FeatureSet(); + $featureSet->setTableGateway($tableGateway); + $featureSet->addFeature($feature); + + static::assertSame($tableGateway, $feature->receivedTableGateway()); + } + #[Test] public function addFeaturesReturnsFluentInterface(): void { @@ -145,6 +164,21 @@ public function applyCallsMethodOnFeatures(): void static::assertTrue(true); } + /** + * A feature that does not implement the method is skipped, not treated as the end + * of the list. + */ + #[Test] + public function applyContinuesPastFeaturesLackingTheMethod(): void + { + $feature = new TestTableGatewayFeature(); + + $featureSet = new FeatureSet([new SequenceFeature('id', 'seq'), $feature]); + $featureSet->apply('recordCall', []); + + static::assertTrue($feature->called); + } + #[Test] public function applyHaltsWhenFeatureReturnsHalt(): void { @@ -290,6 +324,21 @@ public function getFeatureByClassNameReturnsNullWhenNotFound(): void static::assertNull($result); } + /** + * The first feature of the requested class wins; the scan stops rather than + * running on to a later one. + */ + #[Test] + public function getFeatureByClassNameReturnsTheFirstMatch(): void + { + $first = new TestTableGatewayFeature(); + $second = new TestTableGatewayFeature(); + + $featureSet = new FeatureSet([$first, $second]); + + static::assertSame($first, $featureSet->getFeatureByClassName(TestTableGatewayFeature::class)); + } + #[Test] public function getFeatureByClassNameSkipsFeaturesOfAnotherClass(): void { @@ -315,4 +364,21 @@ public function setTableGateway(): void static::assertSame($featureSet, $result); } + + /** + * Features added before the table gateway is known must still receive it. + */ + #[Test] + public function setTableGatewayPropagatesToFeaturesAddedEarlier(): void + { + $feature = new TestTableGatewayFeature(); + + $tableGateway = $this->getMockBuilder(AbstractTableGateway::class) + ->disableOriginalConstructor() + ->getMock(); + + (new FeatureSet([$feature]))->setTableGateway($tableGateway); + + static::assertSame($tableGateway, $feature->receivedTableGateway()); + } } diff --git a/test/unit/TableGateway/Feature/MetadataFeatureTest.php b/test/unit/TableGateway/Feature/MetadataFeatureTest.php index 01ecad78..350642c2 100644 --- a/test/unit/TableGateway/Feature/MetadataFeatureTest.php +++ b/test/unit/TableGateway/Feature/MetadataFeatureTest.php @@ -12,6 +12,7 @@ use PhpDb\TableGateway\AbstractTableGateway; use PhpDb\TableGateway\Exception\RuntimeException; use PhpDb\TableGateway\Feature\MetadataFeature; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\Attributes\Test; @@ -22,6 +23,7 @@ #[IgnoreDeprecations] #[RequiresPhp('<= 8.6')] +#[Group('unit')] class MetadataFeatureTest extends TestCase { #[Test] @@ -39,6 +41,41 @@ public function constructorSetsInitialSharedData(): void static::assertEquals([], $sharedData['metadata']['columns']); } + /** + * A view never reaches the primary key scan, so the null placeholder the constructor + * seeded must survive the column write rather than being dropped. + * + * @throws Exception + * @throws \Exception + */ + #[Test] + public function postInitializeKeepsTheSeededPrimaryKeyPlaceholderForAView(): void + { + /** @var AbstractTableGateway&MockObject $tableGatewayMock */ + $tableGatewayMock = $this->getMockBuilder(AbstractTableGateway::class)->onlyMethods([])->getMock(); + + $tableProperty = new ReflectionProperty(AbstractTableGateway::class, 'table'); + $tableProperty->setValue($tableGatewayMock, 'foo'); + + $metadataMock = $this->getMockBuilder(MetadataInterface::class)->getMock(); + $metadataMock->expects($this->any())->method('getColumnNames')->willReturn(['id', 'name']); + $metadataMock->expects($this->any()) + ->method('getTable') + ->willReturn(new ViewObject('foo')); + + $feature = new MetadataFeature($metadataMock); + $feature->setTableGateway($tableGatewayMock); + $feature->postInitialize(); + + $sharedData = (new ReflectionProperty(MetadataFeature::class, 'sharedData'))->getValue($feature); + + static::assertIsArray($sharedData); + static::assertSame( + ['primaryKey' => null, 'columns' => ['id', 'name']], + $sharedData['metadata'], + ); + } + /** * @throws Exception * @throws \Exception @@ -248,6 +285,49 @@ public function postInitializeThrowsWhenTableIsNotNamed(): void $feature->postInitialize(); } + /** + * The first PRIMARY KEY constraint wins; the scan stops rather than running on and + * overwriting it with a later one. + * + * @throws Exception + * @throws \Exception + */ + #[Test] + public function postInitializeUsesTheFirstPrimaryKeyConstraint(): void + { + /** @var AbstractTableGateway&MockObject $tableGatewayMock */ + $tableGatewayMock = $this->getMockBuilder(AbstractTableGateway::class)->onlyMethods([])->getMock(); + + $tableProperty = new ReflectionProperty(AbstractTableGateway::class, 'table'); + $tableProperty->setValue($tableGatewayMock, 'foo'); + + $metadataMock = $this->getMockBuilder(MetadataInterface::class)->getMock(); + $metadataMock->expects($this->any())->method('getColumnNames')->willReturn(['id', 'name']); + $metadataMock->expects($this->any()) + ->method('getTable') + ->willReturn(new TableObject('foo')); + + $first = new ConstraintObject('id_pk', 'table'); + $first->setColumns(['id']); + $first->setType('PRIMARY KEY'); + + $later = new ConstraintObject('name_pk', 'table'); + $later->setColumns(['name']); + $later->setType('PRIMARY KEY'); + + $metadataMock->expects($this->any())->method('getConstraints')->willReturn([$first, $later]); + + $feature = new MetadataFeature($metadataMock); + $feature->setTableGateway($tableGatewayMock); + $feature->postInitialize(); + + $sharedData = (new ReflectionProperty(MetadataFeature::class, 'sharedData'))->getValue($feature); + + static::assertIsArray($sharedData); + static::assertIsArray($sharedData['metadata']); + static::assertSame('id', $sharedData['metadata']['primaryKey']); + } + /** * @throws Exception * @throws \Exception diff --git a/test/unit/TableGateway/Feature/RowGatewayFeatureTest.php b/test/unit/TableGateway/Feature/RowGatewayFeatureTest.php index 3bbc8234..a50bb3ef 100644 --- a/test/unit/TableGateway/Feature/RowGatewayFeatureTest.php +++ b/test/unit/TableGateway/Feature/RowGatewayFeatureTest.php @@ -7,6 +7,7 @@ use PhpDb\Adapter\AdapterInterface; use PhpDb\ResultSet\ResultSetInterface; use PhpDb\ResultSet\RowPrototypeResultSet; +use PhpDb\RowGateway\RowGateway; use PhpDb\RowGateway\RowGatewayInterface; use PhpDb\TableGateway\AbstractTableGateway; use PhpDb\TableGateway\Exception\RuntimeException; @@ -267,8 +268,7 @@ public function postInitializeWithMetadataFeature(): void $feature->postInitialize(); - $prototype = $resultSet->getRowPrototype(); - static::assertInstanceOf(RowGatewayInterface::class, $prototype); + static::assertInstanceOf(RowGateway::class, $resultSet->getRowPrototype()); } #[Test] @@ -300,8 +300,7 @@ public function postInitializeWithStringPrimaryKey(): void $feature->postInitialize(); - $prototype = $resultSet->getRowPrototype(); - static::assertInstanceOf(RowGatewayInterface::class, $prototype); + static::assertInstanceOf(RowGateway::class, $resultSet->getRowPrototype()); } /** diff --git a/test/unit/TableGateway/Feature/SequenceFeatureTest.php b/test/unit/TableGateway/Feature/SequenceFeatureTest.php index 538b1b84..72083549 100644 --- a/test/unit/TableGateway/Feature/SequenceFeatureTest.php +++ b/test/unit/TableGateway/Feature/SequenceFeatureTest.php @@ -42,6 +42,15 @@ public static function lastSequenceIdProvider(): array ]; } + /** @psalm-return array */ + public static function lastSequenceIdSqlProvider(): array + { + return [ + ['PostgreSQL', 'SELECT LAST_INSERT_ROWID() as "currval"'], + ['Oracle', 'SELECT ' . self::$sequenceName . '.CURRVAL as "currval" FROM dual'], + ]; + } + /** @psalm-return array */ public static function nextSequenceIdProvider(): array { @@ -63,6 +72,42 @@ public function lastSequenceId(string $platformName): void static::assertSame(55, $result); } + /** + * @throws Exception + */ + #[Test] + #[DataProvider('lastSequenceIdSqlProvider')] + public function lastSequenceIdPreparesThePlatformStatement(string $platformName, string $statementSql): void + { + $platform = $this->createMock(PlatformInterface::class); + $platform->expects($this->any())->method('getName')->willReturn($platformName); + $platform->expects($this->any()) + ->method('quoteIdentifier') + ->willReturnCallback(static fn($name) => $name); + + $result = $this->createMock(ResultInterface::class); + $result->expects($this->any())->method('current')->willReturn(['currval' => 7]); + + $statement = $this->createMock(StatementInterface::class); + $statement->expects($this->any())->method('execute')->willReturn($result); + $statement->expects($this->once())->method('prepare')->with($statementSql); + + $adapter = $this->getMockBuilder(Adapter::class) + ->onlyMethods(['getPlatform', 'createStatement']) + ->disableOriginalConstructor() + ->getMock(); + $adapter->expects($this->any())->method('getPlatform')->willReturn($platform); + $adapter->expects($this->once())->method('createStatement')->willReturn($statement); + + $tableGateway = $this->getMockBuilder(TableGateway::class) + ->setConstructorArgs(['table', $adapter]) + ->onlyMethods([]) + ->getMock(); + + $this->feature->setTableGateway($tableGateway); + $this->feature->lastSequenceId(); + } + #[Test] public function lastSequenceIdThrowsExceptionForUnsupportedPlatform(): void { @@ -126,7 +171,7 @@ public function nextSequenceId(string $platformName, string $statementSql): void $statement->expects($this->any()) ->method('execute') ->willReturn($result); - $statement->expects($this->any()) + $statement->expects($this->once()) ->method('prepare') ->with($statementSql); $adapter->expects($this->once()) diff --git a/test/unit/TableGateway/Feature/TestAsset/TestTableGatewayFeature.php b/test/unit/TableGateway/Feature/TestAsset/TestTableGatewayFeature.php index df8926ae..75f675e0 100644 --- a/test/unit/TableGateway/Feature/TestAsset/TestTableGatewayFeature.php +++ b/test/unit/TableGateway/Feature/TestAsset/TestTableGatewayFeature.php @@ -4,6 +4,7 @@ namespace PhpDbTest\TableGateway\Feature\TestAsset; +use PhpDb\TableGateway\AbstractTableGateway; use PhpDb\TableGateway\Feature\AbstractFeature; class TestTableGatewayFeature extends AbstractFeature @@ -31,6 +32,11 @@ public function getMagicMethodSpecifications(): array return $this->magicMethodSpecs; } + public function receivedTableGateway(): ?AbstractTableGateway + { + return $this->tableGateway ?? null; + } + public function recordCall(mixed ...$args): mixed { $this->called = true; From dae29300abb5fe8c84d63f1c951dc8934c6b2ef0 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 28 Sep 2026 13:10:41 +1000 Subject: [PATCH 2/4] Assert the full text of the three diagnostic exception messages Takes the last 11 escaped mutants and the GitHub annotations that came with them. Covered MSI reaches 100%. Three of the four sites already asserted their message, but with a substring short enough that every Concat and ConcatOperandRemoval mutant slipped under it - reordering the operands or dropping one left the assertion passing. Widening them to the whole message closes that. The wording is not the brittle part. Both RowGatewayFeature messages are short phrases wrapped around self::class and RowPrototypeResultSet::class, so a class rename flows through the assertion on its own and only a deliberate rewording breaks it, which is when being told is useful. VunerablePlatformQuoteException had expectException at four call sites and no message assertion anywhere, so it gains a focused test. Its placeholders sit in the first fragment, so the assertion pins that the message names the platform and method before it explains the risk. The only mutants left in the suite are the two pre-existing timeouts in AbstractResultSet. --- .../VunerablePlatformQuoteExceptionTest.php | 32 +++++++++++++++++++ .../Feature/RowGatewayFeatureTest.php | 18 ++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 test/unit/Adapter/Exception/VunerablePlatformQuoteExceptionTest.php diff --git a/test/unit/Adapter/Exception/VunerablePlatformQuoteExceptionTest.php b/test/unit/Adapter/Exception/VunerablePlatformQuoteExceptionTest.php new file mode 100644 index 00000000..6607fe2d --- /dev/null +++ b/test/unit/Adapter/Exception/VunerablePlatformQuoteExceptionTest.php @@ -0,0 +1,32 @@ +getMessage(), + ); + } +} diff --git a/test/unit/TableGateway/Feature/RowGatewayFeatureTest.php b/test/unit/TableGateway/Feature/RowGatewayFeatureTest.php index a50bb3ef..24af3a9c 100644 --- a/test/unit/TableGateway/Feature/RowGatewayFeatureTest.php +++ b/test/unit/TableGateway/Feature/RowGatewayFeatureTest.php @@ -19,8 +19,14 @@ use PHPUnit\Framework\TestCase; use ReflectionProperty; +use function sprintf; + class RowGatewayFeatureTest extends TestCase { + private const string NO_PRIMARY_KEY_MESSAGE = + 'No information was provided to the RowGatewayFeature and/or no MetadataFeature could be consulted ' + . 'to find the primary key necessary for RowGateway object creation.'; + #[Test] public function constructorStoresArguments(): void { @@ -85,7 +91,11 @@ public function postInitializeThrowsExceptionForNonResultSet(): void $feature->setTableGateway($tableGateway); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('expects the ResultSet to be an instance of'); + $this->expectExceptionMessage(sprintf( + 'This feature %s expects the ResultSet to be an instance of %s', + RowGatewayFeature::class, + RowPrototypeResultSet::class, + )); $feature->postInitialize(); } @@ -116,7 +126,7 @@ public function postInitializeThrowsExceptionWhenMetadataHasNoMetadataKey(): voi $feature->setTableGateway($tableGateway); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No information was provided to the RowGatewayFeature'); + $this->expectExceptionMessage(self::NO_PRIMARY_KEY_MESSAGE); $feature->postInitialize(); } @@ -138,7 +148,7 @@ public function postInitializeThrowsExceptionWhenNoMetadataAndNoPrimaryKey(): vo $feature->setTableGateway($tableGateway); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No information was provided to the RowGatewayFeature'); + $this->expectExceptionMessage(self::NO_PRIMARY_KEY_MESSAGE); $feature->postInitialize(); } @@ -214,7 +224,7 @@ public function postInitializeThrowsWhenTableGatewayHasNoFeatureSet(): void $feature->setTableGateway($tableGateway); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No information was provided to the RowGatewayFeature'); + $this->expectExceptionMessage(self::NO_PRIMARY_KEY_MESSAGE); $feature->postInitialize(); } From 72078f0d8f7b0f862dfce185bd84aaa82df4efcc Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 28 Sep 2026 13:20:35 +1000 Subject: [PATCH 3/4] Cover the buffered valid() path directly The two timeouts in AbstractResultSet are infinite-loop mutants on valid(): one turns the && into ||, the other negates the whole condition, and both leave valid() unable to return false, so any foreach over a buffered result set spins until Infection reaps it. Only one covering test reached that branch - the foreach at line 159, the sole test that calls buffer() before iterating. Everywhere else buffer() is never called, the buffer is null, and is_array() short-circuits so neither mutant is observable. That made a whole branch depend on one loop. These two cases drive valid() directly on a buffered set, via a helper that walks the data source to its end with current()/next() rather than a loop, so a valid() that never goes false cannot spin inside them. Applying each mutant by hand confirms they catch it: the || fails the past-the-end case, the negation fails both. The mutants stay classified as timeouts, because the foreach tests still hang earlier in the same process. What changes is that the branch no longer rests on those tests: if they were removed or stopped iterating, these two would kill the mutants outright instead of letting them escape. --- test/unit/ResultSet/AbstractResultSetTest.php | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/unit/ResultSet/AbstractResultSetTest.php b/test/unit/ResultSet/AbstractResultSetTest.php index fc1401b7..6bcd1b6d 100644 --- a/test/unit/ResultSet/AbstractResultSetTest.php +++ b/test/unit/ResultSet/AbstractResultSetTest.php @@ -17,6 +17,7 @@ use PhpDb\ResultSet\Exception\RuntimeException; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use TypeError; @@ -528,6 +529,35 @@ public function getIterator(): ArrayIterator self::assertTrue($resultSet->valid()); } + /** + * A buffered result set must answer from its buffer once the data source is spent, + * rather than deferring to the exhausted source. That second pass is what buffering + * exists for. + * + * @throws Exception + */ + #[Test] + public function validAnswersFromTheBufferAfterTheDataSourceIsExhausted(): void + { + $resultSet = $this->drainIntoBuffer(); + + $resultSet->rewind(); + + static::assertTrue($resultSet->valid()); + } + + /** + * @throws Exception + */ + #[Test] + public function validReturnsFalseBeyondTheEndOfTheBuffer(): void + { + $resultSet = $this->drainIntoBuffer(); + + // Left sitting one past the last buffered row, with nothing left in the source. + static::assertFalse($resultSet->valid()); + } + /** * Sets up the fixture, for example, opens a network connection. * This method is called before a test is executed. @@ -544,4 +574,27 @@ private function createResultSetMock(): MockObject|AbstractResultSet ->onlyMethods(['toArray']) ->getMock(); } + + /** + * Walks a buffered result set to the end of its data source without iterating, so a + * valid() that never goes false cannot spin here. + * + * @throws Exception + */ + private function drainIntoBuffer(): MockObject|AbstractResultSet + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([ + ['id' => 1, 'name' => 'one'], + ['id' => 2, 'name' => 'two'], + ])); + $resultSet->buffer(); + + $resultSet->current(); + $resultSet->next(); + $resultSet->current(); + $resultSet->next(); + + return $resultSet; + } } From 873db833c0f1ffbaf9d36c2f5f368f6d818e8d94 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Tue, 29 Sep 2026 14:42:34 +1000 Subject: [PATCH 4/4] Observe the restored adapter name in the __set_state test testSetStateWithCustomAdapterName asserted only that a delegator came back, which is true whichever way the coalesce falls, so the Coalesce mutant on __set_state() survived: AdapterInterface::class is never null, and no test looked at which service the restored delegator asks for. $adapterName is protected readonly, so the test now observes it through behaviour, invoking the delegator against a container that expects has('custom'). Applying the mutant fails the test; that was the last escaped mutant in the package. Signed-off-by: Simon Mundy --- .../Container/AdapterInterfaceDelegatorTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php b/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php index c263b875..85765b3f 100644 --- a/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php +++ b/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php @@ -291,6 +291,21 @@ public function testSetStateWithCustomAdapterName(): void $delegator = AdapterInterfaceDelegator::__set_state(['adapterName' => 'custom']); self::assertInstanceOf(AdapterInterfaceDelegator::class, $delegator); + + $container = $this->createMock(ContainerInterface::class); + $container->expects(self::once()) + ->method('has') + ->with('custom') + ->willReturn(false); + + self::expectException(ServiceNotFoundException::class); + self::expectExceptionMessage(sprintf(ContainerException::MISSING_SERVICE, 'custom')); + + $delegator( + $container, + ConcreteAdapterAwareObject::class, + static fn(): ConcreteAdapterAwareObject => new ConcreteAdapterAwareObject(), + ); } public function testSetStateWithDefaultAdapterName(): void