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/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 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 b3481394..45cb1d46 100644 --- a/test/unit/Exception/ContainerExceptionTest.php +++ b/test/unit/Exception/ContainerExceptionTest.php @@ -68,6 +68,12 @@ public function forNonAdapterAwareDelegateRendersItsTemplate(): void ); } + #[Test] + public function forServiceCreatesExceptionWithoutACode(): void + { + self::assertSame(0, ContainerException::forService('Svc', 'Factory', 'reason')->getCode()); + } + #[Test] public function forServiceIsAPsrContainerException(): void { 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/ResultSet/AbstractResultSetTest.php b/test/unit/ResultSet/AbstractResultSetTest.php index 0f02c102..93d5fd4b 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; + } } 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 e7c4e2a1..bc3e3176 100644 --- a/test/unit/Sql/Ddl/Column/IntegerTest.php +++ b/test/unit/Sql/Ddl/Column/IntegerTest.php @@ -53,6 +53,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 { @@ -69,6 +94,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 426d8ed3..d59b97ab 100644 --- a/test/unit/Sql/Predicate/BetweenTest.php +++ b/test/unit/Sql/Predicate/BetweenTest.php @@ -11,6 +11,7 @@ use PhpDb\Sql\Predicate\Between; use PhpDb\Sql\Predicate\Exception\InvalidArgumentException; 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 18660afc..e612b9f3 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 e569f0e6..99cf1a75 100644 --- a/test/unit/Sql/Predicate/LikeTest.php +++ b/test/unit/Sql/Predicate/LikeTest.php @@ -10,6 +10,7 @@ use PhpDb\Sql\Predicate\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 b2f7a1fb..61e182ad 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 8fb7da2e..fc59c5df 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 46493dfa..a122e05c 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; @@ -22,6 +23,10 @@ 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 { @@ -273,8 +278,7 @@ public function postInitializeWithMetadataFeature(): void $feature->postInitialize(); - $prototype = $resultSet->getRowPrototype(); - static::assertInstanceOf(RowGatewayInterface::class, $prototype); + static::assertInstanceOf(RowGateway::class, $resultSet->getRowPrototype()); } #[Test] @@ -306,8 +310,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 226172ff..31caa8f6 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;