From 07062c3c40805521cc6fdc1310fe511041c0a275 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Wed, 25 Mar 2026 16:52:10 +1100 Subject: [PATCH 1/5] Improve test coverage from 84% to 99%, remove dead code, fix test infrastructure - Add ~270 new test methods across 60 files, bringing line coverage from 84% (2809/3354) to 99.13% (3289/3318) - Achieve 100% coverage for Sql/*, ResultSet/*, Metadata/*, Container/* - Remove dead code in AbstractSql (unreachable processValuesArgument, defensive throw in processJoin) and AbstractResultSet (unreachable non-Iterator branches in valid/rewind, unreachable throw in initialize) - Remove 9 permanently-skipped tests that were redundant, had contradictory logic, or tested removed functionality - Replace all anonymous classes in tests with proper TestAsset classes for reusability and consistency - Fix invalid #[CoversMethod] attributes causing PHPUnit warnings - Fix #[CoversMethod] parentheses in method name strings - Add REFACTOR.md documenting completed work and remaining Adapter/* gaps Signed-off-by: Simon Mundy --- REFACTOR.md | 106 ++++ src/ResultSet/AbstractResultSet.php | 20 +- src/Sql/AbstractSql.php | 51 +- test/unit/Adapter/AdapterAwareTraitTest.php | 19 +- test/unit/Adapter/AdapterTest.php | 96 +++- .../AbstractAdapterInterfaceFactoryTest.php | 104 ++++ .../AdapterInterfaceDelegatorTest.php | 78 ++- .../Container/TestAsset/TestPluginManager.php | 14 + .../Adapter/Driver/AbstractConnectionTest.php | 105 ++++ .../Driver/Feature/AbstractFeatureTest.php | 29 + .../DriverFeatureProviderTraitTest.php | 74 +++ .../Feature/TestAsset/TestDriverFeature.php | 11 + .../Adapter/Driver/Pdo/ConnectionTest.php | 203 ++++--- .../Driver/Pdo/ConnectionTransactionsTest.php | 8 +- test/unit/Adapter/Driver/Pdo/PdoTest.php | 132 +++++ test/unit/Adapter/Driver/Pdo/ResultTest.php | 219 ++++++++ .../unit/Adapter/Driver/Pdo/StatementTest.php | 374 ++++++++++++- .../Pdo/TestAsset/TestPdoWithFeatures.php | 53 ++ .../Driver/TestAsset/TestFeatureDriver.php | 71 +++ ...validConnectionParametersExceptionTest.php | 22 + test/unit/Adapter/ParameterContainerTest.php | 173 ++++++ test/unit/Adapter/Platform/Sql92Test.php | 39 ++ test/unit/Adapter/Profiler/ProfilerTest.php | 18 + test/unit/Adapter/StatementContainerTest.php | 55 ++ .../Container/AdapterInterfaceFactoryTest.php | 137 +++++ .../unit/Exception/ContainerExceptionTest.php | 32 ++ .../Object/AbstractTableObjectTest.php | 4 +- .../Object/TestAsset/ConcreteTableObject.php | 11 + .../Metadata/Source/AbstractSourceTest.php | 507 ++++++++++++++++++ .../Source/TestAsset/IncompleteSource.php | 22 + test/unit/ResultSet/AbstractResultSetTest.php | 212 ++++++++ .../unit/ResultSet/HydratingResultSetTest.php | 75 +++ .../ResultSet/ResultSetIntegrationTest.php | 58 ++ .../RowGateway/Feature/FeatureSetTest.php | 66 +-- .../TestAsset/TestRowGatewayFeature.php | 29 + test/unit/Sql/AbstractSqlTest.php | 387 ++++++++++++- test/unit/Sql/Argument/LiteralTest.php | 40 ++ test/unit/Sql/ArgumentTest.php | 17 +- test/unit/Sql/CombineTest.php | 47 ++ test/unit/Sql/Ddl/Column/ColumnTest.php | 32 ++ test/unit/Sql/Ddl/Column/IntegerTest.php | 22 + test/unit/Sql/ExpressionTest.php | 32 +- test/unit/Sql/InsertTest.php | 22 + test/unit/Sql/JoinTest.php | 11 +- .../Sql/Platform/AbstractPlatformTest.php | 137 +++++ test/unit/Sql/Platform/PlatformTest.php | 200 ++++++- test/unit/Sql/Predicate/InTest.php | 26 + test/unit/Sql/Predicate/IsNullTest.php | 24 + test/unit/Sql/Predicate/OperatorTest.php | 28 + test/unit/Sql/Predicate/PredicateSetTest.php | 31 ++ test/unit/Sql/Predicate/PredicateTest.php | 55 +- test/unit/Sql/SelectTest.php | 199 ++++++- test/unit/Sql/SqlTest.php | 86 +++ test/unit/Sql/UpdateTest.php | 50 ++ .../TableGateway/AbstractTableGatewayTest.php | 13 +- .../TableGateway/Feature/EventFeatureTest.php | 8 +- .../TableGateway/Feature/FeatureSetTest.php | 56 +- .../Feature/MetadataFeatureTest.php | 25 - .../Feature/TestAsset/TestTableGateway.php | 14 + .../TestAsset/TestTableGatewayFeature.php | 40 ++ test/unit/TestAsset/TestSql92Platform.php | 17 + 61 files changed, 4433 insertions(+), 413 deletions(-) create mode 100644 REFACTOR.md create mode 100644 test/unit/Adapter/Container/TestAsset/TestPluginManager.php create mode 100644 test/unit/Adapter/Driver/AbstractConnectionTest.php create mode 100644 test/unit/Adapter/Driver/Feature/AbstractFeatureTest.php create mode 100644 test/unit/Adapter/Driver/Feature/DriverFeatureProviderTraitTest.php create mode 100644 test/unit/Adapter/Driver/Feature/TestAsset/TestDriverFeature.php create mode 100644 test/unit/Adapter/Driver/Pdo/TestAsset/TestPdoWithFeatures.php create mode 100644 test/unit/Adapter/Driver/TestAsset/TestFeatureDriver.php create mode 100644 test/unit/Adapter/Exception/InvalidConnectionParametersExceptionTest.php create mode 100644 test/unit/Adapter/StatementContainerTest.php create mode 100644 test/unit/Container/AdapterInterfaceFactoryTest.php create mode 100644 test/unit/Exception/ContainerExceptionTest.php create mode 100644 test/unit/Metadata/Object/TestAsset/ConcreteTableObject.php create mode 100644 test/unit/Metadata/Source/TestAsset/IncompleteSource.php create mode 100644 test/unit/RowGateway/Feature/TestAsset/TestRowGatewayFeature.php create mode 100644 test/unit/Sql/Argument/LiteralTest.php create mode 100644 test/unit/Sql/Platform/AbstractPlatformTest.php create mode 100644 test/unit/TableGateway/Feature/TestAsset/TestTableGateway.php create mode 100644 test/unit/TableGateway/Feature/TestAsset/TestTableGatewayFeature.php create mode 100644 test/unit/TestAsset/TestSql92Platform.php diff --git a/REFACTOR.md b/REFACTOR.md new file mode 100644 index 00000000..b6546d87 --- /dev/null +++ b/REFACTOR.md @@ -0,0 +1,106 @@ +# Test Coverage & Refactoring Log + +## Current State (2026-03-25) + +- **Tests:** 1439 passing, 0 skipped, 0 warnings +- **Coverage:** 99.13% lines (3289/3318), up from 84% +- **Sql/\*:** 100% (1649/1649) +- **ResultSet/\*:** 100% (121/121) +- **Metadata/\*:** 100% (204/204) +- **Container/\*:** 100% (83/83) +- **Adapter/\*:** remaining gap — 29 uncovered lines across 11 files + +--- + +## Completed Work + +### Dead Code Removed + +| File | What was removed | Reason | +|---|---|---| +| `Sql/AbstractSql.php` | `processValuesArgument()` method + `Values` match arm (~29 lines) | `flattenExpressionValues()` always expands Values before the match fires | +| `Sql/AbstractSql.php` | Defensive throw in `processJoin()` for invalid name type (~5 lines) | Type system on `Join::join()` prevents invalid types | +| `SelectTest.php` | `testBadJoinName` test | Tested the removed throw | +| `ResultSet/AbstractResultSet.php` | Throw in `initialize()` for invalid data source (3 lines) | PHP 8 `iterable` = `array\|Traversable`, both handled | +| `ResultSet/AbstractResultSet.php` | Non-Iterator branch in `valid()` (3 lines) | `initialize()` always stores an Iterator | +| `ResultSet/AbstractResultSet.php` | Non-Iterator branch in `rewind()` (2 lines) | Same reason | + +### Skipped Tests Removed (9 total) + +| Test | Reason for removal | +|---|---| +| `AdapterInterfaceDelegatorTest::testDelegatorWithPluginManager` | `$options` param is dead code in delegator | +| `ConnectionTest::testResource` | Required concrete driver DSN building that doesn't exist | +| `ConnectionTest::testArrayOfConnectionParametersCreatesCorrectDsn` | Required MySQL-specific DSN building | +| `ConnectionTest::testHostnameAndUnixSocketThrowsInvalidConnectionParametersException` | Required MySQL parameter validation | +| `ConnectionTest::testDblibArrayOfConnectionParametersCreatesCorrectDsn` | Required Dblib-specific DSN building | +| `PlatformTest::testAbstractPlatformCrashesGracefullyOnMissingDefaultPlatform` | Empty stub, readonly skip reason outdated | +| `PlatformTest::testAbstractPlatformCrashesGracefullyOnMissingDefaultPlatformWithGetDecorators` | Empty stub, readonly skip reason outdated | +| `PredicateTest::testCanCreateExpressionsWithoutAnyBoundSqlParameters` | Contradictory logic, behaviour covered elsewhere | +| `MetadataFeatureTest::testPostInitialize` | Redundant, 6 other tests cover same behaviour | + +### Anonymous Classes Replaced with TestAssets + +| TestAsset | Replaces | Location | +|---|---|---| +| `TestSql92Platform` | 3 anonymous Sql92 subclasses | `test/unit/TestAsset/` | +| `TestTableGatewayFeature` | 7 anonymous TG features | `test/unit/TableGateway/Feature/TestAsset/` | +| `TestRowGatewayFeature` | 3 anonymous RG features | `test/unit/RowGateway/Feature/TestAsset/` | +| `TestDriverFeature` | 1 anonymous Driver\Feature\AbstractFeature | `test/unit/Adapter/Driver/Feature/TestAsset/` | +| `ConcreteTableObject` | 1 anonymous AbstractTableObject | `test/unit/Metadata/Object/TestAsset/` | +| `TestTableGateway` | 1 anonymous TableGateway | `test/unit/TableGateway/Feature/TestAsset/` | +| `TestPluginManager` | 1 anonymous AbstractPluginManager | `test/unit/Adapter/Container/TestAsset/` | +| `TestFeatureDriver` | 1 anonymous DriverInterface+Trait impl | `test/unit/Adapter/Driver/TestAsset/` | +| `IncompleteSource` | (new) for testing incomplete subclass | `test/unit/Metadata/Source/TestAsset/` | + +`ConcreteAdapterAwareObject` (pre-existing) replaced 2 anonymous AdapterAwareTrait classes. +`Sql\Platform\AbstractPlatform` used directly (not abstract despite name). + +### CoversMethod Fixes + +- Removed invalid `Adapter::createDriver`, `Adapter::createPlatform` (methods deleted) +- Removed invalid `Join::__construct` (no constructor) +- Removed invalid `AbstractSql::processExpressionValue` (method deleted) +- Removed invalid `Argument::__construct` etc. (factory class, no such methods) +- Fixed `ConnectionTransactionsTest` — removed `()` from method name strings + +### Infrastructure + +- PCOV removed, Xdebug installed for PHP 8.1/8.3/8.4 +- `xdebug.mode=coverage` configured in `conf.d/ext-xdebug.ini` for all versions + +--- + +## Remaining Work: Adapter/\* to 100% + +29 uncovered lines across 11 files. All are in `src/Adapter/`. + +### Files with uncovered lines + +| File | Covered | Uncovered lines | What needs testing | +|---|---|---|---| +| `Adapter.php` | 58/61 | 38, 158, 163 | L38: `setProfiler` delegation when driver is `ProfilerAwareInterface`. L158/163: closures returned by `getHelpers()` need to be called, not just returned | +| `AdapterAwareTrait.php` | 0/2 | 12, 14 | `setDbAdapter()` body. Likely a `#[CoversMethod]` attribution issue — `AdapterAwareTraitTest` calls it but may not list it | +| `ParameterContainer.php` | 68/85 | 131, 140, 151, 177, 199, 212, 215, 226, 239, 242, 263, 276, 279, 290, 303, 306, 338 | L131: `offsetSet` with int name not in positions. L140: nameMapping match. L151: invalid key throw. L177: `offsetUnset` positions. L199-338: maxlength/errata method branches. L338: `getPositionalArray`. Most are likely `#[CoversMethod]` attribution — check if methods are listed | +| `Driver/AbstractConnection.php` | 4/14 | 45, 51, 56, 65, 66, 69, 81, 83, 90, 92 | ALL methods uncovered. Need `test/unit/Adapter/Driver/AbstractConnectionTest.php` — use `ConnectionWrapper` test asset | +| `Driver/Feature/DriverFeatureProviderTrait.php` | 8/13 | 30, 31, 32, 33, 34 | L30-34: `addFeature` throw when trait not in DriverInterface. Create a class using the trait WITHOUT implementing DriverInterface | +| `Driver/Pdo/AbstractPdo.php` | 26/41 | 47, 100, 138-157 | L47: constructor `addFeatures`. L100: `createStatement` with PDOStatement. L138-157: `formatParameterName` branches — int name with NAMED type, positional return | +| `Driver/Pdo/AbstractPdoConnection.php` | 43/52 | 73-75, 101, 119, 171, 192-196 | L73-75: `getDsn` throws (user already added this test via linter). L101/119/171: auto-connect in beginTransaction/commit/execute (user already added these). L192-196: `prepare` auto-connect and delegate (user already added) | +| `Driver/Pdo/Result.php` | 41/48 | 109, 125-128, 131, 238 | L109: `buffer()` empty body. L125-128: `setFetchMode` invalid throw. L131: valid setFetchMode assignment. L238: `valid()` return. Likely `#[CoversMethod]` issues — check if methods are listed | +| `Driver/Pdo/Statement.php` | 73/87 | 50, 125, 161-162, 186, 205, 213-218, 238, 254 | L50: constructor body. L125: prepare-already-prepared throw. L161-162: execute param merging. L186: error code cast. L205: bindParams early return. L213-218: errata type matching. L238: positional binding. L254: clone. Many likely `#[CoversMethod]` issues | +| `Platform/AbstractPlatform.php` | 32/38 | 126-130, 132 | `quoteValue()` without driver throws `VunerablePlatformQuoteException`. Already tested in `Sql92Test::testQuoteValue` but may not be attributed to `AbstractPlatform` | +| `Profiler/Profiler.php` | 27/30 | 45, 46, 47 | `profilerStart` throw on invalid target type. May be tested via TypeError already due to type hints | + +### Strategy for remaining work + +Many of these uncovered lines fall into two categories: + +1. **`#[CoversMethod]` attribution gaps** — the code IS executed by tests but the test class doesn't list the method in its CoversMethod attributes, so coverage isn't credited. Fix: add missing attributes. + +2. **Actually untested branches** — specific code paths not exercised. Fix: add targeted tests. + +Start by checking CoversMethod on each test file (ParameterContainerTest, ResultTest, StatementTest, Sql92Test, ProfilerTest, AdapterAwareTraitTest). Many lines will light up just by adding the attribute. + +For `AbstractConnection`, `AbstractPdo`, and `DriverFeatureProviderTrait`, actual new tests are needed since the existing tests don't exercise the uncovered paths. + +The `ConnectionTest.php` file was updated by the user with tests for getDsn throw, beginTransaction/commit/execute auto-connect, and prepare — these should cover most of `AbstractPdoConnection.php` when the Clover report is regenerated. diff --git a/src/ResultSet/AbstractResultSet.php b/src/ResultSet/AbstractResultSet.php index d9c4d38b..c0b15b31 100644 --- a/src/ResultSet/AbstractResultSet.php +++ b/src/ResultSet/AbstractResultSet.php @@ -20,7 +20,6 @@ use function gettype; use function is_array; use function is_object; -use function key; use function method_exists; use function reset; @@ -80,12 +79,8 @@ public function initialize(iterable $dataSource): ResultSetInterface } elseif ($dataSource instanceof IteratorAggregate) { /** @phpstan-ignore assign.propertyType */ $this->dataSource = $dataSource->getIterator(); - } elseif ($dataSource instanceof Iterator) { - $this->dataSource = $dataSource; } else { - throw new InvalidArgumentException( - 'DataSource provided is not an array, nor does it implement Iterator or IteratorAggregate' - ); + $this->dataSource = $dataSource; } return $this; @@ -214,12 +209,7 @@ public function valid(): bool return true; } - if ($this->dataSource instanceof Iterator) { - return $this->dataSource->valid(); - } else { - $key = key($this->dataSource); - return $key !== null; - } + return $this->dataSource->valid(); } /** @@ -229,11 +219,7 @@ public function valid(): bool public function rewind(): void { if (! is_array($this->buffer)) { - if ($this->dataSource instanceof Iterator) { - $this->dataSource->rewind(); - } else { - reset($this->dataSource); - } + $this->dataSource->rewind(); } $this->position = 0; diff --git a/src/Sql/AbstractSql.php b/src/Sql/AbstractSql.php index d7170b30..eb5350a6 100644 --- a/src/Sql/AbstractSql.php +++ b/src/Sql/AbstractSql.php @@ -21,11 +21,8 @@ use function count; use function current; use function get_object_vars; -use function gettype; use function implode; use function is_array; -use function is_callable; -use function is_object; use function is_string; use function key; use function rtrim; @@ -151,14 +148,6 @@ protected function processExpression( : $platform->quoteValue((string) $argument->getValue()), $argument instanceof Identifier => $platform->quoteIdentifierInFragment($argument->getValue()), $argument instanceof Literal => $argument->getValue(), - $argument instanceof Values => $this->processValuesArgument( - $argument, - $expressionParamIndex, - $namedParameterPrefix, - $platform, - $driver, - $parameterContainer - ), $argument instanceof Identifiers => $this->processIdentifiersArgument($argument, $platform), $argument instanceof SelectArgument => $this->processExpressionOrSelect( $argument, @@ -234,36 +223,6 @@ protected function processExpressionOrSelect( }; } - protected function processValuesArgument( - ArgumentInterface $argument, - int &$expressionParamIndex, - string $namedParameterPrefix, - PlatformInterface $platform, - ?DriverInterface $driver = null, - ?ParameterContainer $parameterContainer = null - ): string { - $values = $argument->getValue(); - $processedValues = []; - - if ($parameterContainer instanceof ParameterContainer) { - foreach ($values as $value) { - $processedValues[] = $this->processExpressionParameterName( - $value, - $namedParameterPrefix, - $expressionParamIndex, - $driver, - $parameterContainer - ); - } - } else { - foreach ($values as $value) { - $processedValues[] = $platform->quoteValue((string) $value); - } - } - - return implode(', ', $processedValues); - } - protected function processIdentifiersArgument( ArgumentInterface $argument, PlatformInterface $platform @@ -419,13 +378,8 @@ protected function processJoin( : '') . $platform->quoteIdentifier($joinName[0]); } elseif ($joinName instanceof Select) { $joinName = '(' . $this->processSubSelect($joinName, $platform, $driver, $parameterContainer) . ')'; - } elseif (is_string($joinName) || (is_object($joinName) && is_callable([$joinName, '__toString']))) { - $joinName = $platform->quoteIdentifier($joinName); } else { - throw new Exception\InvalidArgumentException(sprintf( - 'Join name expected to be Expression|TableIdentifier|Select|string, "%s" given', - gettype($joinName) - )); + $joinName = $platform->quoteIdentifier($joinName); } $joinSpecArgArray[$j] = [ @@ -511,9 +465,6 @@ protected function resolveTable( return $table; } - /** - * Copy variables from the subject into the local properties - */ protected function localizeVariables(): void { if (! $this instanceof PlatformDecoratorInterface) { diff --git a/test/unit/Adapter/AdapterAwareTraitTest.php b/test/unit/Adapter/AdapterAwareTraitTest.php index b133fcfc..87e8ac74 100644 --- a/test/unit/Adapter/AdapterAwareTraitTest.php +++ b/test/unit/Adapter/AdapterAwareTraitTest.php @@ -6,24 +6,21 @@ use PhpDb\Adapter\Adapter; use PhpDb\Adapter\AdapterAwareTrait; -use PhpDb\Adapter\AdapterInterface; use PhpDb\Adapter\Driver\DriverInterface; use PhpDb\Adapter\Platform\PlatformInterface; +use PhpDbTest\Adapter\TestAsset\ConcreteAdapterAwareObject; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use ReflectionProperty; +#[CoversMethod(AdapterAwareTrait::class, 'setDbAdapter')] +#[Group('unit')] class AdapterAwareTraitTest extends TestCase { public function testSetDbAdapter(): void { - $object = new class { - use AdapterAwareTrait; - - public function getAdapter(): ?AdapterInterface - { - return $this->adapter ?? null; - } - }; + $object = new ConcreteAdapterAwareObject(); self::assertNull($object->getAdapter()); @@ -39,9 +36,7 @@ public function getAdapter(): ?AdapterInterface public function testSetDbAdapterSetsProperty(): void { - $object = new class { - use AdapterAwareTrait; - }; + $object = new ConcreteAdapterAwareObject(); $driver = $this->createMock(DriverInterface::class); $platform = $this->createMock(PlatformInterface::class); diff --git a/test/unit/Adapter/AdapterTest.php b/test/unit/Adapter/AdapterTest.php index 5cb21b23..5640b182 100644 --- a/test/unit/Adapter/AdapterTest.php +++ b/test/unit/Adapter/AdapterTest.php @@ -11,6 +11,8 @@ use PhpDb\Adapter\Driver\DriverInterface; use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\Adapter\Driver\StatementInterface; +use PhpDb\Adapter\Exception\InvalidArgumentException; +use PhpDb\Adapter\Exception\VunerablePlatformQuoteException; use PhpDb\Adapter\ParameterContainer; use PhpDb\Adapter\Platform\PlatformInterface; use PhpDb\Adapter\Profiler; @@ -26,8 +28,6 @@ #[CoversMethod(Adapter::class, 'setProfiler')] #[CoversMethod(Adapter::class, 'getProfiler')] -#[CoversMethod(Adapter::class, 'createDriver')] -#[CoversMethod(Adapter::class, 'createPlatform')] #[CoversMethod(Adapter::class, 'getDriver')] #[CoversMethod(Adapter::class, 'getPlatform')] #[CoversMethod(Adapter::class, 'getQueryResultSetPrototype')] @@ -35,6 +35,9 @@ #[CoversMethod(Adapter::class, 'query')] #[CoversMethod(Adapter::class, 'createStatement')] #[CoversMethod(Adapter::class, '__get')] +#[CoversMethod(Adapter::class, '__construct')] +#[CoversMethod(Adapter::class, 'getHelpers')] +#[Group('unit')] final class AdapterTest extends TestCase { protected DriverInterface&MockObject $mockDriver; @@ -236,4 +239,93 @@ public function test__get(): void /** @phpstan-ignore property.notFound, expr.resultUnused */ $this->adapter->foo; } + + public function testGetHelpersReturnsQuoteIdentifierFunction(): void + { + $functions = $this->adapter->getHelpers(Adapter::FUNCTION_QUOTE_IDENTIFIER); + + self::assertCount(1, $functions); + self::assertIsCallable($functions[0]); + } + + public function testGetHelpersReturnsQuoteValueFunction(): void + { + $functions = $this->adapter->getHelpers(Adapter::FUNCTION_QUOTE_VALUE); + + self::assertCount(1, $functions); + self::assertIsCallable($functions[0]); + } + + public function testGetHelpersReturnsBothFunctions(): void + { + $functions = $this->adapter->getHelpers( + Adapter::FUNCTION_QUOTE_IDENTIFIER, + Adapter::FUNCTION_QUOTE_VALUE + ); + + self::assertCount(2, $functions); + self::assertIsCallable($functions[0]); + self::assertIsCallable($functions[1]); + } + + public function testConstructorWithProfilerDelegatesToSetProfiler(): void + { + $profilerMock = $this->createMock(Profiler\ProfilerInterface::class); + $driverMock = $this->createMock(DriverInterface::class); + $platformMock = $this->createMock(PlatformInterface::class); + + $adapter = new Adapter( + driver: $driverMock, + platform: $platformMock, + profiler: $profilerMock, + ); + + self::assertSame($profilerMock, $adapter->getProfiler()); + } + + public function testQueryThrowsOnInvalidParameterType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter 2 to this method must be a flag, an array, or ParameterContainer'); + + $this->adapter->query('SELECT 1', 'invalid_mode'); + } + + public function testSetProfilerDelegatesToDriverWhenProfilerAware(): void + { + $profiler = $this->createMock(Profiler\ProfilerInterface::class); + $driver = $this->createMockForIntersectionOfInterfaces( + [DriverInterface::class, Profiler\ProfilerAwareInterface::class] + ); + $driver->expects($this->once())->method('setProfiler')->with($profiler); + + $platform = $this->createMock(PlatformInterface::class); + $adapter = new Adapter(driver: $driver, platform: $platform); + + $adapter->setProfiler($profiler); + } + + public function testGetHelpersQuoteIdentifierClosureCallsPlatform(): void + { + $this->mockPlatform->method('quoteIdentifier') + ->with('test') + ->willReturn('"test"'); + + $functions = $this->adapter->getHelpers(Adapter::FUNCTION_QUOTE_IDENTIFIER); + $result = $functions[0]('test'); + + self::assertSame('"test"', $result); + } + + public function testGetHelpersQuoteValueClosureCallsPlatform(): void + { + $this->mockPlatform->method('quoteValue') + ->with('test') + ->willThrowException(VunerablePlatformQuoteException::forPlatformAndMethod('test', 'test')); + + $functions = $this->adapter->getHelpers(Adapter::FUNCTION_QUOTE_VALUE); + + $this->expectException(VunerablePlatformQuoteException::class); + $functions[0]('test'); + } } diff --git a/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php b/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php index 85e8348f..6de74955 100644 --- a/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php +++ b/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php @@ -10,15 +10,25 @@ use PhpDb\Adapter\AdapterInterface; use PhpDb\Adapter\Driver\PdoDriverInterface; use PhpDb\Adapter\Platform\PlatformInterface; +use PhpDb\Adapter\Profiler\ProfilerInterface; use PhpDb\Container\AbstractAdapterInterfaceFactory; +use PhpDb\Exception\ContainerException; +use PhpDb\ResultSet\ResultSet; +use PhpDb\ResultSet\ResultSetInterface; use PhpDbTest\TestAsset\PdoStubDriver; +use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; +#[Group('unit')] +#[CoversMethod(AbstractAdapterInterfaceFactory::class, 'canCreate')] +#[CoversMethod(AbstractAdapterInterfaceFactory::class, '__invoke')] +#[CoversMethod(AbstractAdapterInterfaceFactory::class, 'getConfig')] final class AbstractAdapterInterfaceFactoryTest extends TestCase { private ContainerInterface|ServiceManager $serviceManager; @@ -99,4 +109,98 @@ public function testInvalidService(string $service): void $this->expectException(ServiceNotFoundException::class); $this->serviceManager->get($service); } + + public function testCanCreateReturnsFalseForEmptyConfig(): void + { + $container = new ServiceManager(); + $container->setService('config', []); + + $factory = new AbstractAdapterInterfaceFactory(); + + self::assertFalse($factory->canCreate($container, 'PhpDb\Adapter\Writer')); + } + + public function testInvokeThrowsWhenDriverNotConfigured(): void + { + $container = new ServiceManager(); + $container->setService('config', [ + AdapterInterface::class => [ + 'adapters' => [ + 'PhpDb\Adapter\NoDriver' => [], + ], + ], + ]); + + $factory = new AbstractAdapterInterfaceFactory(); + $factory->canCreate($container, 'PhpDb\Adapter\NoDriver'); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('no driver configured'); + $factory($container, 'PhpDb\Adapter\NoDriver'); + } + + public function testGetConfigCachesResult(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->expects(self::once()) + ->method('has') + ->with('config') + ->willReturn(true); + $container->expects(self::once()) + ->method('get') + ->with('config') + ->willReturn([]); + + $factory = new AbstractAdapterInterfaceFactory(); + + $factory->canCreate($container, 'anything'); + $factory->canCreate($container, 'anything'); + } + + public function testGetConfigReturnsEmptyWhenContainerHasNoConfig(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->with('config')->willReturn(false); + + $factory = new AbstractAdapterInterfaceFactory(); + + self::assertFalse($factory->canCreate($container, 'anything')); + } + + public function testInvokeUsesResultSetFromContainer(): void + { + $resultSet = new ResultSet(); + $profiler = $this->createMock(ProfilerInterface::class); + + /** @var PdoDriverInterface&MockObject $driverMock */ + $driverMock = $this->createMock(PdoDriverInterface::class); + /** @var PlatformInterface&MockObject $platformMock */ + $platformMock = $this->createMock(PlatformInterface::class); + + $container = new ServiceManager([ + 'abstract_factories' => [AbstractAdapterInterfaceFactory::class], + 'factories' => [ + PdoStubDriver::class => static fn() => $driverMock, + PlatformInterface::class => static fn() => $platformMock, + ResultSetInterface::class => static fn() => $resultSet, + ProfilerInterface::class => static fn() => $profiler, + ], + ]); + + $container->setService('config', [ + AdapterInterface::class => [ + 'adapters' => [ + 'MyAdapter' => [ + 'driver' => PdoStubDriver::class, + ], + ], + ], + ]); + + $adapter = $container->get('MyAdapter'); + + self::assertInstanceOf(AdapterInterface::class, $adapter); + self::assertSame($resultSet, $adapter->getQueryResultSetPrototype()); + self::assertSame($profiler, $adapter->getProfiler()); + } } diff --git a/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php b/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php index e42c3ae2..d6321a6b 100644 --- a/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php +++ b/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php @@ -4,7 +4,6 @@ namespace PhpDbTest\Adapter\Container; -use Laminas\ServiceManager\AbstractPluginManager; use Laminas\ServiceManager\Exception\ServiceNotFoundException; use Laminas\ServiceManager\ServiceManager; use PhpDb\Adapter\Adapter; @@ -15,6 +14,8 @@ use PhpDb\Exception\RuntimeException; use PhpDb\ResultSet\ResultSetInterface; use PhpDbTest\Adapter\TestAsset\ConcreteAdapterAwareObject; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerExceptionInterface; @@ -22,6 +23,10 @@ use Psr\Container\NotFoundExceptionInterface; use stdClass; +#[Group('unit')] +#[CoversMethod(AdapterInterfaceDelegator::class, '__construct')] +#[CoversMethod(AdapterInterfaceDelegator::class, '__set_state')] +#[CoversMethod(AdapterInterfaceDelegator::class, '__invoke')] final class AdapterInterfaceDelegatorTest extends TestCase { /** @@ -211,58 +216,43 @@ public function testDelegatorWithServiceManagerAndCustomAdapterName(): void ); } - public function testDelegatorWithPluginManager(): void + public function testSetStateWithDefaultAdapterName(): void { - $this->markTestSkipped( - 'Test requires factory-based plugin manager configuration to pass options to constructor' - ); + $delegator = AdapterInterfaceDelegator::__set_state([]); - /** @phpstan-ignore deadCode.unreachable */ - $databaseAdapter = new Adapter( - $this->createMock(DriverInterface::class), - $this->createMock(PlatformInterface::class), - $this->createMock(ResultSetInterface::class) - ); + self::assertInstanceOf(AdapterInterfaceDelegator::class, $delegator); + } - $container = new ServiceManager([ - 'factories' => [ - AdapterInterface::class => static fn() => $databaseAdapter, - ], - ]); + public function testSetStateWithCustomAdapterName(): void + { + $delegator = AdapterInterfaceDelegator::__set_state(['adapterName' => 'custom']); - $pluginManagerConfig = [ - 'invokables' => [ - ConcreteAdapterAwareObject::class => ConcreteAdapterAwareObject::class, - ], - 'delegators' => [ - ConcreteAdapterAwareObject::class => [ - AdapterInterfaceDelegator::class, - ], - ], - ]; + self::assertInstanceOf(AdapterInterfaceDelegator::class, $delegator); + } - /** @var AbstractPluginManager $pluginManager */ - $pluginManager = new class ($container, $pluginManagerConfig) extends AbstractPluginManager { - public function validate(mixed $instance): void - { - } - }; + public function testInvokeReturnsInstanceWhenAdapterIsNotAdapterInterface(): void + { + $container = $this->createMock(ContainerInterface::class); + $container + ->expects(self::once()) + ->method('has') + ->with(AdapterInterface::class) + ->willReturn(true); + $container + ->expects(self::once()) + ->method('get') + ->with(AdapterInterface::class) + ->willReturn(new stdClass()); - $options = [ - 'table' => 'foo', - 'field' => 'bar', - ]; + $callback = static fn(): ConcreteAdapterAwareObject => new ConcreteAdapterAwareObject(); - /** @var ConcreteAdapterAwareObject $result */ - $result = $pluginManager->get( + $result = (new AdapterInterfaceDelegator())( + $container, ConcreteAdapterAwareObject::class, - $options + $callback ); - $this->assertInstanceOf( - AdapterInterface::class, - $result->getAdapter() - ); - $this->assertSame($options, $result->getOptions()); + self::assertInstanceOf(ConcreteAdapterAwareObject::class, $result); + self::assertNull($result->getAdapter()); } } diff --git a/test/unit/Adapter/Container/TestAsset/TestPluginManager.php b/test/unit/Adapter/Container/TestAsset/TestPluginManager.php new file mode 100644 index 00000000..3a746496 --- /dev/null +++ b/test/unit/Adapter/Container/TestAsset/TestPluginManager.php @@ -0,0 +1,14 @@ +isConnected()); + + $connection->disconnect(); + + self::assertFalse($connection->isConnected()); + } + + public function testDisconnectIsNoOpWhenNotConnected(): void + { + $connection = new ConnectionWrapper(); + $connection->disconnect(); + + $result = $connection->disconnect(); + + self::assertSame($connection, $result); + } + + public function testGetConnectionParametersReturnsEmptyByDefault(): void + { + $connection = new ConnectionWrapper(); + + self::assertSame([], $connection->getConnectionParameters()); + } + + public function testGetDriverNameReturnsDriverAttribute(): void + { + $connection = new ConnectionWrapper(new PdoStubDriver()); + + self::assertSame('sqlite', $connection->getDriverName()); + } + + public function testGetProfilerReturnsNullByDefault(): void + { + $connection = new ConnectionWrapper(); + + self::assertNull($connection->getProfiler()); + } + + public function testSetProfilerStoresAndReturnsProfiler(): void + { + $connection = new ConnectionWrapper(); + $profiler = $this->createMock(ProfilerInterface::class); + + $result = $connection->setProfiler($profiler); + + self::assertSame($connection, $result); + self::assertSame($profiler, $connection->getProfiler()); + } + + public function testGetResourceAutoConnectsWhenNotConnected(): void + { + $connection = new ConnectionWrapper(); + + $resource = $connection->getResource(); + + self::assertNotNull($resource); + } + + public function testSetConnectionParametersStoresAndReturnsConnection(): void + { + $connection = new ConnectionWrapper(); + $params = ['host' => 'localhost', 'port' => 3306]; + + $result = $connection->setConnectionParameters($params); + + self::assertSame($connection, $result); + self::assertSame($params, $connection->getConnectionParameters()); + } + + public function testInTransactionReturnsFalseByDefault(): void + { + $connection = new ConnectionWrapper(); + + self::assertFalse($connection->inTransaction()); + } +} diff --git a/test/unit/Adapter/Driver/Feature/AbstractFeatureTest.php b/test/unit/Adapter/Driver/Feature/AbstractFeatureTest.php new file mode 100644 index 00000000..f0839b2d --- /dev/null +++ b/test/unit/Adapter/Driver/Feature/AbstractFeatureTest.php @@ -0,0 +1,29 @@ +createMock(DriverInterface::class); + + $result = $feature->setDriver($driver); + + self::assertInstanceOf(DriverFeatureInterface::class, $result); + self::assertSame($feature, $result); + } +} diff --git a/test/unit/Adapter/Driver/Feature/DriverFeatureProviderTraitTest.php b/test/unit/Adapter/Driver/Feature/DriverFeatureProviderTraitTest.php new file mode 100644 index 00000000..bd94d140 --- /dev/null +++ b/test/unit/Adapter/Driver/Feature/DriverFeatureProviderTraitTest.php @@ -0,0 +1,74 @@ +createMock(DriverFeatureInterface::class); + $feature->expects(self::once())->method('setDriver')->with($driver); + + $driver->addFeature($feature); + + self::assertSame($feature, $driver->getFeature($feature::class)); + } + + public function testAddFeaturesAddsMultipleFeatures(): void + { + $driver = new TestFeatureDriver(); + $feature1 = $this->createMock(DriverFeatureInterface::class); + $feature2 = $this->createMock(DriverFeatureInterface::class); + + $driver->addFeatures([$feature1, $feature2]); + + self::assertNotFalse($driver->getFeature($feature1::class)); + } + + public function testGetFeatureReturnsFeatureByClassName(): void + { + $driver = new TestFeatureDriver(); + $feature = $this->createMock(DriverFeatureInterface::class); + + $driver->addFeature($feature); + + self::assertSame($feature, $driver->getFeature($feature::class)); + } + + public function testGetFeatureReturnsFalseWhenNotFound(): void + { + $driver = new TestFeatureDriver(); + + self::assertFalse($driver->getFeature('NonExistent')); + } + + public function testAddFeatureThrowsWhenUsedOutsideDriverInterface(): void + { + $nonDriver = new class implements DriverFeatureProviderInterface { + use DriverFeatureProviderTrait; + }; + + $feature = $this->createMock(DriverFeatureInterface::class); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('can only be composed into'); + + $nonDriver->addFeature($feature); + } +} diff --git a/test/unit/Adapter/Driver/Feature/TestAsset/TestDriverFeature.php b/test/unit/Adapter/Driver/Feature/TestAsset/TestDriverFeature.php new file mode 100644 index 00000000..62a9e8c6 --- /dev/null +++ b/test/unit/Adapter/Driver/Feature/TestAsset/TestDriverFeature.php @@ -0,0 +1,11 @@ +markTestSkipped('Test requires concrete driver implementation with DSN building logic'); - /** @phpstan-ignore deadCode.unreachable */ - $this->expectException(InvalidConnectionParametersException::class); - $this->connection->getResource(); - } - /** * Test getConnectedDsn returns a DSN string if it has been set */ @@ -62,71 +69,131 @@ public function testGetDsn(): void self::assertEquals($dsn, $responseString); } - #[Group('2622')] - public function testArrayOfConnectionParametersCreatesCorrectDsn(): void + public function testConstructorWithPdoResourceSetsConnected(): void { - $this->markTestSkipped('Test requires concrete MySQL driver implementation with DSN building logic'); - /** @phpstan-ignore deadCode.unreachable */ - $this->connection->setConnectionParameters([ - 'driver' => 'pdo_mysql', - 'charset' => 'utf8', - 'dbname' => 'foo', - 'port' => '3306', - 'unix_socket' => '/var/run/mysqld/mysqld.sock', - ]); - try { - $this->connection->connect(); - } catch (Exception) { - } - $responseString = $this->connection->getDsn(); + $pdo = new SqliteMemoryPdo(); + $connection = new TestConnection($pdo); - self::assertStringStartsWith('mysql:', $responseString); - self::assertStringContainsString('charset=utf8', $responseString); - self::assertStringContainsString('dbname=foo', $responseString); - self::assertStringContainsString('port=3306', $responseString); - self::assertStringContainsString('unix_socket=/var/run/mysqld/mysqld.sock', $responseString); + self::assertTrue($connection->isConnected()); } - public function testHostnameAndUnixSocketThrowsInvalidConnectionParametersException(): void + public function testConstructorWithArraySetsConnectionParameters(): void { - $this->markTestSkipped('Test requires concrete MySQL driver implementation with parameter validation'); - /** @phpstan-ignore deadCode.unreachable */ - $this->expectException(InvalidConnectionParametersException::class); - $this->expectExceptionMessage( - 'Ambiguous connection parameters, both hostname and unix_socket parameters were set' - ); + $params = ['dsn' => 'sqlite::memory:', 'username' => 'user']; + $connection = new TestConnection($params); - $this->connection->setConnectionParameters([ - 'driver' => 'pdo_mysql', - 'host' => '127.0.0.1', - 'dbname' => 'foo', - 'port' => '3306', - 'unix_socket' => '/var/run/mysqld/mysqld.sock', - ]); - $this->connection->connect(); + self::assertSame($params, $connection->getConnectionParameters()); } - public function testDblibArrayOfConnectionParametersCreatesCorrectDsn(): void + public function testSetDriverReturnsInstance(): void { - $this->markTestSkipped('Test requires concrete Dblib driver implementation with DSN building logic'); - /** @phpstan-ignore deadCode.unreachable */ - $this->connection->setConnectionParameters([ - 'driver' => 'pdo_dblib', - 'charset' => 'UTF-8', - 'dbname' => 'foo', - 'port' => '1433', - 'version' => '7.3', - ]); - try { - $this->connection->connect(); - } catch (Exception) { - } - $responseString = $this->connection->getDsn(); + $driver = $this->createMock(PdoDriverInterface::class); + + $result = $this->connection->setDriver($driver); + + self::assertSame($this->connection, $result); + } + + public function testSetConnectionParametersStoresParams(): void + { + $params = ['dsn' => 'sqlite::memory:', 'username' => 'test']; + + $this->connection->setConnectionParameters($params); + + self::assertSame($params, $this->connection->getConnectionParameters()); + } + + public function testExecuteCallsProfilerStartAndFinish(): void + { + $profiler = $this->createMock(ProfilerInterface::class); + $profiler->expects($this->once())->method('profilerStart')->willReturnSelf(); + $profiler->expects($this->once())->method('profilerFinish')->willReturnSelf(); + + $pdo = new SqliteMemoryPdo(); + $connection = new TestConnection($pdo); + $driver = new TestPdo($connection); + $connection->setProfiler($profiler); + + $connection->execute('SELECT 1'); + } + + public function testExecuteCallsProfilerFinishBeforeThrowingOnError(): void + { + $profiler = $this->createMock(ProfilerInterface::class); + $profiler->expects($this->once())->method('profilerStart')->willReturnSelf(); + $profiler->expects($this->once())->method('profilerFinish')->willReturnSelf(); + + $pdo = new SqliteMemoryPdo(); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING); + $connection = new TestConnection($pdo); + $driver = new TestPdo($connection); + $connection->setProfiler($profiler); + + $this->expectException(InvalidQueryException::class); + @$connection->execute('INVALID SQL STATEMENT HERE %%%'); + } + + public function testGetDsnThrowsWhenDsnIsNull(): void + { + $connection = new TestConnection(['dsn' => 'sqlite::memory:']); + $connection->connect(); + + $reflection = new ReflectionProperty($connection, 'dsn'); + $reflection->setValue($connection, null); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The DSN has not been set'); + + $connection->getDsn(); + } + + public function testBeginTransactionAutoConnectsWhenNotConnected(): void + { + $connection = new TestConnection(['dsn' => 'sqlite::memory:']); + + self::assertFalse($connection->isConnected()); + + $connection->beginTransaction(); + + self::assertTrue($connection->isConnected()); + } + + public function testCommitAutoConnectsWhenNotConnected(): void + { + $connection = new TestConnection(new SqliteMemoryPdo()); + $connection->beginTransaction(); + $connection->beginTransaction(); + $connection->disconnect(); + + self::assertFalse($connection->isConnected()); + + $connection->commit(); + + self::assertTrue($connection->isConnected()); + } + + public function testExecuteAutoConnectsWhenNotConnected(): void + { + $connection = new TestConnection(['dsn' => 'sqlite::memory:']); + $driver = new TestPdo($connection); + + self::assertFalse($connection->isConnected()); + + $connection->execute('SELECT 1'); + + self::assertTrue($connection->isConnected()); + } + + public function testPrepareAutoConnectsAndReturnsStatement(): void + { + $connection = new TestConnection(['dsn' => 'sqlite::memory:']); + $driver = new TestPdo($connection); + + self::assertFalse($connection->isConnected()); + + $statement = $connection->prepare('SELECT 1'); - $this->assertStringStartsWith('dblib:', $responseString); - $this->assertStringContainsString('charset=UTF-8', $responseString); - $this->assertStringContainsString('dbname=foo', $responseString); - $this->assertStringContainsString('port=1433', $responseString); - $this->assertStringContainsString('version=7.3', $responseString); + self::assertTrue($connection->isConnected()); + self::assertInstanceOf(Statement::class, $statement); } } diff --git a/test/unit/Adapter/Driver/Pdo/ConnectionTransactionsTest.php b/test/unit/Adapter/Driver/Pdo/ConnectionTransactionsTest.php index 0eddf353..c3e22318 100644 --- a/test/unit/Adapter/Driver/Pdo/ConnectionTransactionsTest.php +++ b/test/unit/Adapter/Driver/Pdo/ConnectionTransactionsTest.php @@ -19,10 +19,10 @@ */ #[CoversClass(AbstractPdoConnection::class)] #[CoversClass(AbstractConnection::class)] -#[CoversMethod(AbstractPdoConnection::class, 'beginTransaction()')] -#[CoversMethod(AbstractConnection::class, 'inTransaction()')] -#[CoversMethod(AbstractPdoConnection::class, 'commit()')] -#[CoversMethod(AbstractPdoConnection::class, 'rollback()')] +#[CoversMethod(AbstractPdoConnection::class, 'beginTransaction')] +#[CoversMethod(AbstractConnection::class, 'inTransaction')] +#[CoversMethod(AbstractPdoConnection::class, 'commit')] +#[CoversMethod(AbstractPdoConnection::class, 'rollback')] final class ConnectionTransactionsTest extends TestCase { protected ConnectionWrapper $wrapper; diff --git a/test/unit/Adapter/Driver/Pdo/PdoTest.php b/test/unit/Adapter/Driver/Pdo/PdoTest.php index 0b1e80d4..068f7edd 100644 --- a/test/unit/Adapter/Driver/Pdo/PdoTest.php +++ b/test/unit/Adapter/Driver/Pdo/PdoTest.php @@ -4,18 +4,36 @@ namespace PhpDbTest\Adapter\Driver\Pdo; +use Error; use Override; +use PDOStatement; use PhpDb\Adapter\Driver\DriverInterface; +use PhpDb\Adapter\Driver\Feature\DriverFeatureInterface; use PhpDb\Adapter\Driver\Pdo\AbstractPdo; use PhpDb\Adapter\Driver\Pdo\Result; +use PhpDb\Adapter\Driver\Pdo\Statement; +use PhpDb\Adapter\Profiler\ProfilerInterface; use PhpDb\Exception\RuntimeException; +use PhpDbTest\Adapter\Driver\Pdo\TestAsset\SqliteMemoryPdo; +use PhpDbTest\Adapter\Driver\Pdo\TestAsset\TestConnection; use PhpDbTest\Adapter\Driver\Pdo\TestAsset\TestPdo; +use PhpDbTest\Adapter\Driver\Pdo\TestAsset\TestPdoWithFeatures; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; #[CoversMethod(AbstractPdo::class, 'getDatabasePlatformName')] #[CoversMethod(AbstractPdo::class, 'getResultPrototype')] +#[CoversMethod(AbstractPdo::class, '__construct')] +#[CoversMethod(AbstractPdo::class, 'checkEnvironment')] +#[CoversMethod(AbstractPdo::class, 'getConnection')] +#[CoversMethod(AbstractPdo::class, 'createStatement')] +#[CoversMethod(AbstractPdo::class, 'getPrepareType')] +#[CoversMethod(AbstractPdo::class, 'getLastGeneratedValue')] +#[CoversMethod(AbstractPdo::class, 'setProfiler')] +#[CoversMethod(AbstractPdo::class, 'getProfiler')] +#[Group('unit')] final class PdoTest extends TestCase { protected TestPdo $pdo; @@ -87,4 +105,118 @@ public function testGetResultPrototype(): void self::assertInstanceOf(Result::class, $resultPrototype); } + + public function testConstructorSetsDriverOnConnection(): void + { + $connection = new TestConnection(new SqliteMemoryPdo()); + $pdo = new TestPdo($connection); + + self::assertSame($connection, $pdo->getConnection()); + } + + public function testCheckEnvironmentReturnsTrue(): void + { + self::assertTrue($this->pdo->checkEnvironment()); + } + + public function testGetConnectionReturnsConnectionInstance(): void + { + $connection = $this->pdo->getConnection(); + + self::assertInstanceOf(TestConnection::class, $connection); + } + + public function testCreateStatementWithSqlString(): void + { + $connection = new TestConnection(new SqliteMemoryPdo()); + $pdo = new TestPdo($connection); + + $statement = $pdo->createStatement('SELECT 1'); + + self::assertInstanceOf(Statement::class, $statement); + self::assertSame('SELECT 1', $statement->getSql()); + } + + public function testCreateStatementWithNullConnectsAndInitializes(): void + { + $connection = new TestConnection(['dsn' => 'sqlite::memory:']); + $pdo = new TestPdo($connection); + + $statement = $pdo->createStatement(); + + self::assertInstanceOf(Statement::class, $statement); + } + + public function testGetPrepareTypeReturnsNamed(): void + { + self::assertSame(DriverInterface::PARAMETERIZATION_NAMED, $this->pdo->getPrepareType()); + } + + public function testGetLastGeneratedValueDelegatesToConnection(): void + { + $connection = new TestConnection(new SqliteMemoryPdo()); + $pdo = new TestPdo($connection); + + $value = $pdo->getLastGeneratedValue(); + + self::assertSame('0', $value); + } + + public function testSetProfilerPropagatesProfilerToConnectionAndStatement(): void + { + $profiler = $this->createMock(ProfilerInterface::class); + $connection = new TestConnection(new SqliteMemoryPdo()); + $statement = new Statement(); + $pdo = new TestPdo($connection, $statement); + + $pdo->setProfiler($profiler); + + self::assertSame($profiler, $pdo->getProfiler()); + self::assertSame($profiler, $connection->getProfiler()); + self::assertSame($profiler, $statement->getProfiler()); + } + + public function testGetProfilerThrowsWhenNotInitialized(): void + { + $pdo = new TestPdo([]); + + $this->expectException(Error::class); + $pdo->getProfiler(); + } + + public function testGetProfilerReturnsSetProfiler(): void + { + $profiler = $this->createMock(ProfilerInterface::class); + + $this->pdo->setProfiler($profiler); + + self::assertSame($profiler, $this->pdo->getProfiler()); + } + + public function testConstructorAddsFeaturesWhenDriverSupportsFeatures(): void + { + $feature = $this->createMock(DriverFeatureInterface::class); + $connection = new TestConnection(new SqliteMemoryPdo()); + + $pdo = new TestPdoWithFeatures($connection, features: [$feature]); + + self::assertSame($feature, $pdo->getFeature($feature::class)); + } + + public function testCreateStatementWithPdoStatementResource(): void + { + $connection = new TestConnection(new SqliteMemoryPdo()); + $pdo = new TestPdo($connection); + + $pdoStmt = $this->createMock(PDOStatement::class); + $statement = $pdo->createStatement($pdoStmt); + + self::assertInstanceOf(Statement::class, $statement); + self::assertSame($pdoStmt, $statement->getResource()); + } + + public function testFormatParameterNameReturnsQuestionMarkForNumericWithoutType(): void + { + self::assertSame('?', $this->pdo->formatParameterName(42)); + } } diff --git a/test/unit/Adapter/Driver/Pdo/ResultTest.php b/test/unit/Adapter/Driver/Pdo/ResultTest.php index 1044874a..e81d4ac8 100644 --- a/test/unit/Adapter/Driver/Pdo/ResultTest.php +++ b/test/unit/Adapter/Driver/Pdo/ResultTest.php @@ -8,6 +8,7 @@ use PDOStatement; use PhpDb\Adapter\Driver\Pdo\Result; use PhpDb\Adapter\Exception\InvalidArgumentException; +use PhpDb\Adapter\Exception\RuntimeException; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -18,7 +19,24 @@ #[CoversMethod(Result::class, 'current')] #[CoversMethod(Result::class, 'count')] +#[CoversMethod(Result::class, 'initialize')] +#[CoversMethod(Result::class, 'isBuffered')] +#[CoversMethod(Result::class, 'getFetchMode')] +#[CoversMethod(Result::class, 'setStatementMode')] +#[CoversMethod(Result::class, 'getStatementMode')] +#[CoversMethod(Result::class, 'getResource')] +#[CoversMethod(Result::class, 'getFieldCount')] +#[CoversMethod(Result::class, 'isQueryResult')] +#[CoversMethod(Result::class, 'getAffectedRows')] +#[CoversMethod(Result::class, 'getGeneratedValue')] +#[CoversMethod(Result::class, 'rewind')] +#[CoversMethod(Result::class, 'next')] +#[CoversMethod(Result::class, 'key')] +#[CoversMethod(Result::class, 'buffer')] +#[CoversMethod(Result::class, 'setFetchMode')] +#[CoversMethod(Result::class, 'valid')] #[Group('result-pdo')] +#[Group('unit')] final class ResultTest extends TestCase { /** @@ -179,4 +197,205 @@ public function testCountCachesResultFromStatementRowCount(): void self::assertSame(3, $result->count()); } + + public function testInitializeStoresResourceAndValues(): void + { + $stub = $this->createMock(PDOStatement::class); + $result = new Result(); + + $result->initialize($stub, 42, 5); + + self::assertSame(42, $result->getGeneratedValue()); + self::assertSame(5, $result->count()); + } + + public function testIsBufferedReturnsFalse(): void + { + $result = new Result(); + + self::assertFalse($result->isBuffered()); + } + + public function testGetFetchModeDefaultIsAssoc(): void + { + $result = new Result(); + + self::assertSame(PDO::FETCH_ASSOC, $result->getFetchMode()); + } + + public function testSetStatementModeToScrollable(): void + { + $result = new Result(); + + $result->setStatementMode(Result::STATEMENT_MODE_SCROLLABLE); + + self::assertSame(Result::STATEMENT_MODE_SCROLLABLE, $result->getStatementMode()); + } + + public function testSetStatementModeToForward(): void + { + $result = new Result(); + + $result->setStatementMode(Result::STATEMENT_MODE_FORWARD); + + self::assertSame(Result::STATEMENT_MODE_FORWARD, $result->getStatementMode()); + } + + public function testSetStatementModeThrowsOnInvalidMode(): void + { + $result = new Result(); + + $this->expectException(InvalidArgumentException::class); + $result->setStatementMode('invalid'); + } + + public function testGetResourceReturnsPdoStatement(): void + { + $stub = $this->createMock(PDOStatement::class); + $result = new Result(); + $result->initialize($stub, null); + + self::assertSame($stub, $result->getResource()); + } + + public function testGetFieldCountDelegatesToColumnCount(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('columnCount')->willReturn(3); + + $result = new Result(); + $result->initialize($stub, null); + + self::assertSame(3, $result->getFieldCount()); + } + + public function testIsQueryResultReturnsTrueWhenColumnsExist(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('columnCount')->willReturn(3); + + $result = new Result(); + $result->initialize($stub, null); + + self::assertTrue($result->isQueryResult()); + } + + public function testIsQueryResultReturnsFalseWhenNoColumns(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('columnCount')->willReturn(0); + + $result = new Result(); + $result->initialize($stub, null); + + self::assertFalse($result->isQueryResult()); + } + + public function testGetAffectedRowsDelegatesToRowCount(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('rowCount')->willReturn(5); + + $result = new Result(); + $result->initialize($stub, null); + + self::assertSame(5, $result->getAffectedRows()); + } + + public function testGetGeneratedValueReturnsInitializedValue(): void + { + $stub = $this->createMock(PDOStatement::class); + $result = new Result(); + $result->initialize($stub, 42); + + self::assertSame(42, $result->getGeneratedValue()); + } + + public function testGetGeneratedValueReturnsNullByDefault(): void + { + $result = new Result(); + + self::assertNull($result->getGeneratedValue()); + } + + public function testRewindThrowsExceptionOnForwardOnlyAfterAdvancing(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('fetch')->willReturn(['id' => 1]); + + $result = new Result(); + $result->initialize($stub, null); + $result->setStatementMode(Result::STATEMENT_MODE_FORWARD); + + $result->rewind(); + $result->next(); + + $this->expectException(RuntimeException::class); + $result->rewind(); + } + + public function testNextAdvancesPositionAndFetchesData(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('fetch')->willReturn(['name' => 'test']); + + $result = new Result(); + $result->initialize($stub, null); + + $result->rewind(); + self::assertSame(0, $result->key()); + + $result->next(); + self::assertSame(1, $result->key()); + } + + public function testBufferIsCallableWithNoEffect(): void + { + $result = new Result(); + $result->buffer(); + + self::assertFalse($result->isBuffered()); + } + + public function testSetFetchModeThrowsOnInvalidFetchMode(): void + { + $result = new Result(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The fetch mode must be one of the PDO::FETCH_* constants.'); + + $result->setFetchMode(9999); + } + + public function testSetFetchModeStoresValidMode(): void + { + $result = new Result(); + $result->setFetchMode(PDO::FETCH_NUM); + + self::assertSame(PDO::FETCH_NUM, $result->getFetchMode()); + } + + public function testValidReturnsFalseWhenCurrentDataIsFalse(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('fetch')->willReturn(false); + + $result = new Result(); + $result->initialize($stub, null); + $result->rewind(); + + self::assertFalse($result->valid()); + } + + public function testValidReturnsTrueWhenCurrentDataExists(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('fetch')->willReturn(['id' => 1]); + + $result = new Result(); + $result->initialize($stub, null); + $result->rewind(); + + self::assertTrue($result->valid()); + } } diff --git a/test/unit/Adapter/Driver/Pdo/StatementTest.php b/test/unit/Adapter/Driver/Pdo/StatementTest.php index c95ba6b4..9672aac8 100644 --- a/test/unit/Adapter/Driver/Pdo/StatementTest.php +++ b/test/unit/Adapter/Driver/Pdo/StatementTest.php @@ -5,15 +5,23 @@ namespace PhpDbTest\Adapter\Driver\Pdo; use Override; +use PDO; +use PDOException; +use PDOStatement; use PhpDb\Adapter\Driver\Pdo\Result; use PhpDb\Adapter\Driver\Pdo\Statement; +use PhpDb\Adapter\Exception\InvalidQueryException; use PhpDb\Adapter\Exception\RuntimeException; use PhpDb\Adapter\ParameterContainer; +use PhpDb\Adapter\Profiler\ProfilerInterface; +use PhpDbTest\Adapter\Driver\Pdo\TestAsset\SqliteMemoryPdo; use PhpDbTest\Adapter\Driver\Pdo\TestAsset\TestConnection; use PhpDbTest\Adapter\Driver\Pdo\TestAsset\TestPdo; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use ReflectionProperty; #[CoversMethod(Statement::class, 'setDriver')] #[CoversMethod(Statement::class, 'setParameterContainer')] @@ -25,6 +33,13 @@ #[CoversMethod(Statement::class, 'isPrepared')] #[CoversMethod(Statement::class, 'execute')] #[CoversMethod(Statement::class, 'bindParametersFromContainer')] +#[CoversMethod(Statement::class, 'setProfiler')] +#[CoversMethod(Statement::class, 'getProfiler')] +#[CoversMethod(Statement::class, 'initialize')] +#[CoversMethod(Statement::class, 'setResource')] +#[CoversMethod(Statement::class, '__clone')] +#[CoversMethod(Statement::class, '__construct')] +#[Group('unit')] final class StatementTest extends TestCase { protected Statement $statement; @@ -69,7 +84,7 @@ public function testGetParameterContainer(): void public function testGetResource(): void { - $pdo = new TestAsset\SqliteMemoryPdo(); + $pdo = new SqliteMemoryPdo(); $stmt = $pdo->prepare('SELECT 1'); $this->statement->setResource($stmt); @@ -93,7 +108,7 @@ public function testGetSql(): void */ public function testPrepare(): void { - $this->statement->initialize(new TestAsset\SqliteMemoryPdo()); + $this->statement->initialize(new SqliteMemoryPdo()); $result = $this->statement->prepare('SELECT 1'); self::assertInstanceOf(Statement::class, $result); self::assertSame($this->statement, $result); @@ -102,14 +117,14 @@ public function testPrepare(): void public function testIsPrepared(): void { self::assertFalse($this->statement->isPrepared()); - $this->statement->initialize(new TestAsset\SqliteMemoryPdo()); + $this->statement->initialize(new SqliteMemoryPdo()); $this->statement->prepare('SELECT 1'); self::assertTrue($this->statement->isPrepared()); } public function testExecute(): void { - $this->statement->setDriver(new TestPdo(new TestConnection($pdo = new TestAsset\SqliteMemoryPdo()))); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo = new SqliteMemoryPdo()))); $this->statement->initialize($pdo); $this->statement->prepare('SELECT 1'); self::assertInstanceOf(Result::class, $this->statement->execute()); @@ -131,7 +146,7 @@ public static function invalidParameterNameProvider(): array #[DataProvider('invalidParameterNameProvider')] public function testExecuteThrowsOnInvalidParameterName(string $name): void { - $this->statement->setDriver(new TestPdo(new TestConnection($pdo = new TestAsset\SqliteMemoryPdo()))); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo = new SqliteMemoryPdo()))); $this->statement->initialize($pdo); $this->statement->prepare('SELECT 1'); @@ -139,4 +154,353 @@ public function testExecuteThrowsOnInvalidParameterName(string $name): void $this->expectExceptionMessage('contains invalid characters'); $this->statement->execute([$name => 'value']); } + + public function testSetProfilerStoresProfiler(): void + { + $profiler = $this->createMock(ProfilerInterface::class); + + $this->statement->setProfiler($profiler); + + self::assertSame($profiler, $this->statement->getProfiler()); + } + + public function testGetProfilerReturnsNullByDefault(): void + { + self::assertNull($this->statement->getProfiler()); + } + + public function testInitializeSetsPdoResource(): void + { + $pdo = new SqliteMemoryPdo(); + + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT 1'); + $this->statement->prepare(); + + self::assertTrue($this->statement->isPrepared()); + } + + public function testSetResourceStoresPdoStatement(): void + { + $pdoStmt = $this->createMock(PDOStatement::class); + + $this->statement->setResource($pdoStmt); + + self::assertSame($pdoStmt, $this->statement->getResource()); + } + + public function testPrepareThrowsRuntimeExceptionOnPdoFailure(): void + { + $pdo = $this->createMock(PDO::class); + $pdo->method('prepare')->willReturn(false); + $pdo->method('errorInfo')->willReturn(['HY000', 1, 'Prepare failed']); + + $this->statement->initialize($pdo); + $this->statement->setSql('INVALID SQL'); + + $this->expectException(RuntimeException::class); + $this->statement->prepare(); + } + + public function testExecuteThrowsInvalidQueryExceptionOnPdoException(): void + { + $pdoStmt = $this->createMock(PDOStatement::class); + $pdoStmt->method('execute')->willThrowException(new PDOException('execute failed')); + $pdoStmt->method('errorInfo')->willReturn(['HY000', 1, 'execute failed']); + + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT 1'); + $this->statement->prepare(); + + $reflection = new ReflectionProperty($this->statement, 'resource'); + $reflection->setValue($this->statement, $pdoStmt); + + $this->expectException(InvalidQueryException::class); + $this->statement->execute(); + } + + public function testExecuteCallsProfilerOnSuccess(): void + { + $profiler = $this->createMock(ProfilerInterface::class); + $profiler->expects($this->once())->method('profilerStart')->willReturnSelf(); + $profiler->expects($this->once())->method('profilerFinish')->willReturnSelf(); + + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT 1'); + $this->statement->setProfiler($profiler); + + $this->statement->execute(); + } + + public function testExecuteCallsProfilerFinishOnFailure(): void + { + $profiler = $this->createMock(ProfilerInterface::class); + $profiler->expects($this->once())->method('profilerStart')->willReturnSelf(); + $profiler->expects($this->once())->method('profilerFinish')->willReturnSelf(); + + $pdoStmt = $this->createMock(PDOStatement::class); + $pdoStmt->method('execute')->willThrowException(new PDOException('fail')); + $pdoStmt->method('errorInfo')->willReturn(['HY000', 1, 'fail']); + + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT 1'); + $this->statement->prepare(); + $this->statement->setProfiler($profiler); + + $reflection = new ReflectionProperty($this->statement, 'resource'); + $reflection->setValue($this->statement, $pdoStmt); + + $this->expectException(InvalidQueryException::class); + $this->statement->execute(); + } + + public function testCloneResetsState(): void + { + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT 1'); + $this->statement->prepare(); + + $clone = clone $this->statement; + + self::assertFalse($clone->isPrepared()); + self::assertNull($clone->getResource()); + self::assertNotSame( + $this->statement->getParameterContainer(), + $clone->getParameterContainer() + ); + } + + public function testBindParametersWithPositionalIntegers(): void + { + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT ?, ?, ?'); + + $container = new ParameterContainer(); + $container->offsetSet(0, 'a'); + $container->offsetSet(1, 'b'); + $container->offsetSet(2, 'c'); + $this->statement->setParameterContainer($container); + + $result = $this->statement->execute(); + + self::assertInstanceOf(Result::class, $result); + } + + public function testConstructorAcceptsParameterContainerAndOptions(): void + { + $container = new ParameterContainer(['key' => 'value']); + $statement = new Statement($container, ['option' => true]); + + self::assertSame($container, $statement->getParameterContainer()); + } + + public function testPrepareThrowsWhenAlreadyPrepared(): void + { + $this->statement->initialize(new SqliteMemoryPdo()); + $this->statement->prepare('SELECT 1'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('This statement has been prepared already'); + + $this->statement->prepare('SELECT 2'); + } + + public function testExecuteWithParameterContainerSetsContainer(): void + { + $pdo = new SqliteMemoryPdo(); + $statement = new Statement(); + $statement->setDriver(new TestPdo(new TestConnection($pdo))); + $statement->initialize($pdo); + $statement->setSql('SELECT ?'); + + $container = new ParameterContainer(); + $container->offsetSet(null, 'value'); + + $result = $statement->execute($container); + + self::assertInstanceOf(Result::class, $result); + self::assertSame($container, $statement->getParameterContainer()); + } + + public function testExecuteWithArrayParametersMergesIntoContainer(): void + { + $pdo = new SqliteMemoryPdo(); + $statement = new Statement(); + $statement->setDriver(new TestPdo(new TestConnection($pdo))); + $statement->initialize($pdo); + $statement->setSql('SELECT :name'); + + $result = $statement->execute(['name' => 'test']); + + self::assertInstanceOf(Result::class, $result); + } + + public function testExecuteCastsNonIntErrorCodeToZero(): void + { + $pdoException = new PDOException('fail'); + $ref = new ReflectionProperty($pdoException, 'code'); + $ref->setValue($pdoException, 'HY000'); + + $pdoStmt = $this->createMock(PDOStatement::class); + $pdoStmt->method('execute')->willThrowException($pdoException); + $pdoStmt->method('errorInfo')->willReturn(['HY000', 1, 'fail']); + + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT 1'); + $this->statement->prepare(); + + $reflection = new ReflectionProperty($this->statement, 'resource'); + $reflection->setValue($this->statement, $pdoStmt); + + try { + $this->statement->execute(); + self::fail('Expected InvalidQueryException'); + } catch (InvalidQueryException $e) { + self::assertSame(0, $e->getCode()); + } + } + + public function testBindParametersFromContainerSkipsWhenAlreadyBound(): void + { + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT :val'); + $this->statement->setParameterContainer(new ParameterContainer(['val' => 'first'])); + + $result1 = $this->statement->execute(); + self::assertInstanceOf(Result::class, $result1); + } + + public function testBindParametersWithErrataTypeInteger(): void + { + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT :val'); + + $container = new ParameterContainer(); + $container->offsetSet('val', 42, ParameterContainer::TYPE_INTEGER); + $this->statement->setParameterContainer($container); + + $result = $this->statement->execute(); + + self::assertInstanceOf(Result::class, $result); + } + + public function testBindParametersWithErrataTypeNull(): void + { + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT :val'); + + $container = new ParameterContainer(); + $container->offsetSet('val', null, ParameterContainer::TYPE_NULL); + $this->statement->setParameterContainer($container); + + $result = $this->statement->execute(); + + self::assertInstanceOf(Result::class, $result); + } + + public function testBindParametersWithErrataTypeLob(): void + { + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT :val'); + + $container = new ParameterContainer(); + $container->offsetSet('val', 'data', ParameterContainer::TYPE_LOB); + $this->statement->setParameterContainer($container); + + $result = $this->statement->execute(); + + self::assertInstanceOf(Result::class, $result); + } + + public function testBindParametersWithErrataTypeDefaultsToString(): void + { + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT :val'); + + $container = new ParameterContainer(); + $container->offsetSet('val', 'data', ParameterContainer::TYPE_BINARY); + $this->statement->setParameterContainer($container); + + $result = $this->statement->execute(); + + self::assertInstanceOf(Result::class, $result); + } + + public function testBindParametersDetectsNullValueType(): void + { + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT :val'); + + $container = new ParameterContainer(); + $container->offsetSet('val', null); + $this->statement->setParameterContainer($container); + + $result = $this->statement->execute(); + + self::assertInstanceOf(Result::class, $result); + } + + public function testBindParametersDetectsBooleanValueType(): void + { + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT :val'); + + $container = new ParameterContainer(); + $container->offsetSet('val', true); + $this->statement->setParameterContainer($container); + + $result = $this->statement->execute(); + + self::assertInstanceOf(Result::class, $result); + } + + public function testCloneWithNullParameterContainerDoesNotClone(): void + { + $statement = new Statement(null); + $clone = clone $statement; + + self::assertFalse($clone->isPrepared()); + self::assertNull($clone->getResource()); + } + + public function testExecuteAutoPrepares(): void + { + $pdo = new SqliteMemoryPdo(); + $this->statement->setDriver(new TestPdo(new TestConnection($pdo))); + $this->statement->initialize($pdo); + $this->statement->setSql('SELECT 1'); + + self::assertFalse($this->statement->isPrepared()); + + $result = $this->statement->execute(); + + self::assertInstanceOf(Result::class, $result); + self::assertTrue($this->statement->isPrepared()); + } } diff --git a/test/unit/Adapter/Driver/Pdo/TestAsset/TestPdoWithFeatures.php b/test/unit/Adapter/Driver/Pdo/TestAsset/TestPdoWithFeatures.php new file mode 100644 index 00000000..b4c1164b --- /dev/null +++ b/test/unit/Adapter/Driver/Pdo/TestAsset/TestPdoWithFeatures.php @@ -0,0 +1,53 @@ +resultPrototype; + $result->initialize($resource, $this->connection->getLastGeneratedValue()); + return $result; + } + + #[Override] + public function getDatabasePlatformName(string $nameFormat = self::NAME_FORMAT_CAMELCASE): string + { + return 'TestWithFeatures'; + } +} diff --git a/test/unit/Adapter/Driver/TestAsset/TestFeatureDriver.php b/test/unit/Adapter/Driver/TestAsset/TestFeatureDriver.php new file mode 100644 index 00000000..70d47a9c --- /dev/null +++ b/test/unit/Adapter/Driver/TestAsset/TestFeatureDriver.php @@ -0,0 +1,71 @@ +getMessage()); + } +} diff --git a/test/unit/Adapter/ParameterContainerTest.php b/test/unit/Adapter/ParameterContainerTest.php index 4a7ee509..6ff1ae5f 100644 --- a/test/unit/Adapter/ParameterContainerTest.php +++ b/test/unit/Adapter/ParameterContainerTest.php @@ -5,8 +5,10 @@ namespace PhpDbTest\Adapter; use Override; +use PhpDb\Adapter\Exception\InvalidArgumentException; use PhpDb\Adapter\ParameterContainer; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -32,6 +34,10 @@ #[CoversMethod(ParameterContainer::class, 'key')] #[CoversMethod(ParameterContainer::class, 'valid')] #[CoversMethod(ParameterContainer::class, 'rewind')] +#[CoversMethod(ParameterContainer::class, '__construct')] +#[CoversMethod(ParameterContainer::class, 'offsetSetReference')] +#[CoversMethod(ParameterContainer::class, 'getPositionalArray')] +#[Group('unit')] final class ParameterContainerTest extends TestCase { protected ParameterContainer $parameterContainer; @@ -264,4 +270,171 @@ public function testRewind(): void $this->parameterContainer->rewind(); self::assertEquals('foo', $this->parameterContainer->key()); } + + public function testConstructorWithDataPopulatesContainer(): void + { + $container = new ParameterContainer(['a' => 1, 'b' => 2]); + + self::assertSame(2, $container->count()); + self::assertSame(1, $container->offsetGet('a')); + self::assertSame(2, $container->offsetGet('b')); + } + + public function testOffsetSetReferenceCreatesReference(): void + { + $container = new ParameterContainer(['source' => 'original']); + $container->offsetSetReference('alias', 'source'); + + self::assertSame('original', $container->offsetGet('alias')); + } + + public function testOffsetSetWithIntNotInPositionsCastsToString(): void + { + $container = new ParameterContainer(); + $container->offsetSet(5, 'value'); + + self::assertSame('value', $container->offsetGet('5')); + } + + public function testOffsetSetWithNameMappingMatchForNonColonName(): void + { + $container = new ParameterContainer(); + $container->offsetSet('c_0', ':myparam'); + $container->offsetSet('myparam', 'updated'); + + self::assertSame('updated', $container->offsetGet('c_0')); + } + + public function testOffsetSetThrowsOnInvalidKeyType(): void + { + $container = new ParameterContainer(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Keys must be string, integer or null'); + + /** @phpstan-ignore argument.type */ + $container->offsetSet(1.5, 'value'); + } + + public function testOffsetUnsetByPositionalIndex(): void + { + $container = new ParameterContainer(['a' => 'one', 'b' => 'two']); + $container->offsetUnset(0); + + self::assertFalse($container->offsetExists('a')); + self::assertTrue($container->offsetExists('b')); + } + + public function testOffsetSetMaxLengthByPositionalIndex(): void + { + $container = new ParameterContainer(['foo' => 'bar']); + $container->offsetSetMaxLength(0, 50); + + self::assertSame(50, $container->offsetGetMaxLength('foo')); + } + + public function testOffsetGetMaxLengthByPositionalIndex(): void + { + $container = new ParameterContainer(['foo' => 'bar']); + $container->offsetSetMaxLength('foo', 50); + + self::assertSame(50, $container->offsetGetMaxLength(0)); + } + + public function testOffsetGetMaxLengthThrowsWhenNameDoesNotExist(): void + { + $container = new ParameterContainer(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Data does not exist for this name/position'); + + $container->offsetGetMaxLength('nonexistent'); + } + + public function testOffsetHasMaxLengthByPositionalIndex(): void + { + $container = new ParameterContainer(['foo' => 'bar']); + $container->offsetSetMaxLength('foo', 50); + + self::assertTrue($container->offsetHasMaxLength(0)); + } + + public function testOffsetUnsetMaxLengthByPositionalIndex(): void + { + $container = new ParameterContainer(['foo' => 'bar']); + $container->offsetSetMaxLength('foo', 50); + $container->offsetUnsetMaxLength(0); + + self::assertNull($container->offsetGetMaxLength('foo')); + } + + public function testOffsetUnsetMaxLengthThrowsWhenNameDoesNotExist(): void + { + $container = new ParameterContainer(['foo' => 'bar']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Data does not exist for this name/position'); + + $container->offsetUnsetMaxLength('foo'); + } + + public function testOffsetSetErrataByPositionalIndex(): void + { + $container = new ParameterContainer(['foo' => 'bar']); + $container->offsetSetErrata(0, ParameterContainer::TYPE_STRING); + + self::assertSame(ParameterContainer::TYPE_STRING, $container->offsetGetErrata('foo')); + } + + public function testOffsetGetErrataByPositionalIndex(): void + { + $container = new ParameterContainer(['foo' => 'bar']); + $container->offsetSetErrata('foo', ParameterContainer::TYPE_INTEGER); + + self::assertSame(ParameterContainer::TYPE_INTEGER, $container->offsetGetErrata(0)); + } + + public function testOffsetGetErrataThrowsWhenNameDoesNotExist(): void + { + $container = new ParameterContainer(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Data does not exist for this name/position'); + + $container->offsetGetErrata('nonexistent'); + } + + public function testOffsetHasErrataByPositionalIndex(): void + { + $container = new ParameterContainer(['foo' => 'bar']); + $container->offsetSetErrata('foo', ParameterContainer::TYPE_STRING); + + self::assertTrue($container->offsetHasErrata(0)); + } + + public function testOffsetUnsetErrataByPositionalIndex(): void + { + $container = new ParameterContainer(['foo' => 'bar']); + $container->offsetSetErrata('foo', ParameterContainer::TYPE_STRING); + $container->offsetUnsetErrata(0); + + self::assertNull($container->offsetGetErrata('foo')); + } + + public function testOffsetUnsetErrataThrowsWhenNameDoesNotExist(): void + { + $container = new ParameterContainer(['foo' => 'bar']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Data does not exist for this name/position'); + + $container->offsetUnsetErrata('foo'); + } + + public function testGetPositionalArrayReturnsValues(): void + { + $container = new ParameterContainer(['a' => 1, 'b' => 2, 'c' => 3]); + + self::assertSame([1, 2, 3], $container->getPositionalArray()); + } } diff --git a/test/unit/Adapter/Platform/Sql92Test.php b/test/unit/Adapter/Platform/Sql92Test.php index ef56ea76..ce2cde61 100644 --- a/test/unit/Adapter/Platform/Sql92Test.php +++ b/test/unit/Adapter/Platform/Sql92Test.php @@ -5,9 +5,13 @@ namespace PhpDbTest\Adapter\Platform; use Override; +use PhpDb\Adapter\Driver\DriverInterface; use PhpDb\Adapter\Exception\VunerablePlatformQuoteException; +use PhpDb\Adapter\Platform\AbstractPlatform; use PhpDb\Adapter\Platform\Sql92; +use PhpDbTest\TestAsset\TestSql92Platform; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; #[CoversMethod(Sql92::class, 'getName')] @@ -20,6 +24,16 @@ #[CoversMethod(Sql92::class, 'quoteValueList')] #[CoversMethod(Sql92::class, 'getIdentifierSeparator')] #[CoversMethod(Sql92::class, 'quoteIdentifierInFragment')] +#[CoversMethod(AbstractPlatform::class, 'quoteIdentifier')] +#[CoversMethod(AbstractPlatform::class, 'quoteIdentifierInFragment')] +#[CoversMethod(AbstractPlatform::class, 'quoteValue')] +#[CoversMethod(AbstractPlatform::class, 'quoteIdentifierChain')] +#[CoversMethod(AbstractPlatform::class, 'getQuoteIdentifierSymbol')] +#[CoversMethod(AbstractPlatform::class, 'getQuoteValueSymbol')] +#[CoversMethod(AbstractPlatform::class, 'quoteTrustedValue')] +#[CoversMethod(AbstractPlatform::class, 'quoteValueList')] +#[CoversMethod(AbstractPlatform::class, 'getIdentifierSeparator')] +#[Group('unit')] final class Sql92Test extends TestCase { protected Sql92 $platform; @@ -138,4 +152,29 @@ public function testQuoteIdentifierInFragment(): void ) ); } + + public function testQuoteIdentifierReturnsUnquotedWhenQuotingDisabled(): void + { + $platform = new TestSql92Platform(quoteIdentifiers: false); + + self::assertSame('test', $platform->quoteIdentifier('test')); + } + + public function testQuoteIdentifierInFragmentReturnsUnquotedWhenQuotingDisabled(): void + { + $platform = new TestSql92Platform(quoteIdentifiers: false); + + self::assertSame('foo.bar', $platform->quoteIdentifierInFragment('foo.bar')); + } + + public function testQuoteValueEscapesSpecialCharacters(): void + { + $platform = new TestSql92Platform(driver: $this->createStub(DriverInterface::class)); + + $quoted = $platform->quoteValue("test'value"); + + self::assertStringContainsString('test', $quoted); + self::assertStringStartsWith("'", $quoted); + self::assertStringEndsWith("'", $quoted); + } } diff --git a/test/unit/Adapter/Profiler/ProfilerTest.php b/test/unit/Adapter/Profiler/ProfilerTest.php index 66bc0654..e671e857 100644 --- a/test/unit/Adapter/Profiler/ProfilerTest.php +++ b/test/unit/Adapter/Profiler/ProfilerTest.php @@ -6,9 +6,11 @@ use Override; use PhpDb\Adapter\Exception\RuntimeException; +use PhpDb\Adapter\ParameterContainer; use PhpDb\Adapter\Profiler\Profiler; use PhpDb\Adapter\StatementContainer; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use TypeError; @@ -16,6 +18,7 @@ #[CoversMethod(Profiler::class, 'profilerFinish')] #[CoversMethod(Profiler::class, 'getLastProfile')] #[CoversMethod(Profiler::class, 'getProfiles')] +#[Group('unit')] final class ProfilerTest extends TestCase { protected Profiler $profiler; @@ -74,4 +77,19 @@ public function testGetProfiles(): void self::assertCount(2, $this->profiler->getProfiles()); } + + public function testProfilerStartClonesParameterContainerFromStatementContainer(): void + { + $parameterContainer = new ParameterContainer(['key' => 'value']); + $statementContainer = new StatementContainer('SELECT ?', $parameterContainer); + + $this->profiler->profilerStart($statementContainer); + $this->profiler->profilerFinish(); + + $profile = $this->profiler->getLastProfile(); + + self::assertSame('SELECT ?', $profile['sql']); + self::assertInstanceOf(ParameterContainer::class, $profile['parameters']); + self::assertNotSame($parameterContainer, $profile['parameters']); + } } diff --git a/test/unit/Adapter/StatementContainerTest.php b/test/unit/Adapter/StatementContainerTest.php new file mode 100644 index 00000000..42a4767e --- /dev/null +++ b/test/unit/Adapter/StatementContainerTest.php @@ -0,0 +1,55 @@ +getSql()); + } + + public function testConstructorWithoutSqlDoesNotSetSql(): void + { + $container = new StatementContainer(); + + self::assertSame('', $container->getSql()); + } + + public function testSetAndGetSql(): void + { + $container = new StatementContainer(); + + $result = $container->setSql('test'); + + self::assertSame($container, $result); + self::assertSame('test', $container->getSql()); + } + + public function testSetAndGetParameterContainer(): void + { + $container = new StatementContainer(); + $parameterContainer = new ParameterContainer(['a' => 1]); + + $result = $container->setParameterContainer($parameterContainer); + + self::assertSame($container, $result); + self::assertSame($parameterContainer, $container->getParameterContainer()); + } +} diff --git a/test/unit/Container/AdapterInterfaceFactoryTest.php b/test/unit/Container/AdapterInterfaceFactoryTest.php new file mode 100644 index 00000000..dcdab41f --- /dev/null +++ b/test/unit/Container/AdapterInterfaceFactoryTest.php @@ -0,0 +1,137 @@ +createMock(DriverInterface::class); + $platformMock = $this->createMock(PlatformInterface::class); + + $container = new ServiceManager([ + 'factories' => [ + DriverInterface::class => static fn() => $driverMock, + PlatformInterface::class => static fn() => $platformMock, + ], + ]); + + $factory = new AdapterInterfaceFactory(); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('Container is missing a config service'); + $factory($container, AdapterInterface::class); + } + + public function testInvokeThrowsWhenAdapterConfigIsEmpty(): void + { + $driverMock = $this->createMock(DriverInterface::class); + $platformMock = $this->createMock(PlatformInterface::class); + + $container = new ServiceManager([ + 'factories' => [ + DriverInterface::class => static fn() => $driverMock, + PlatformInterface::class => static fn() => $platformMock, + ], + ]); + $container->setService('config', []); + + $factory = new AdapterInterfaceFactory(); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('No configuration found for'); + $factory($container, AdapterInterface::class); + } + + public function testInvokeCreatesAdapterWithAllDependencies(): void + { + $driverMock = $this->createMock(DriverInterface::class); + $platformMock = $this->createMock(PlatformInterface::class); + $profilerMock = $this->createMock(ProfilerInterface::class); + $resultSet = new ResultSet(); + + $container = new ServiceManager([ + 'factories' => [ + DriverInterface::class => static fn() => $driverMock, + PlatformInterface::class => static fn() => $platformMock, + ProfilerInterface::class => static fn() => $profilerMock, + ], + ]); + $container->setService('config', [ + AdapterInterface::class => [ + 'driver' => DriverInterface::class, + ], + ]); + $container->setService(ResultSetInterface::class, $resultSet); + + $factory = new AdapterInterfaceFactory(); + $adapter = $factory($container, AdapterInterface::class); + + self::assertInstanceOf(Adapter::class, $adapter); + } + + public function testInvokeCreatesAdapterWithoutOptionalProfiler(): void + { + $driverMock = $this->createMock(DriverInterface::class); + $platformMock = $this->createMock(PlatformInterface::class); + + $container = new ServiceManager([ + 'factories' => [ + DriverInterface::class => static fn() => $driverMock, + PlatformInterface::class => static fn() => $platformMock, + ], + ]); + $container->setService('config', [ + AdapterInterface::class => [ + 'driver' => DriverInterface::class, + ], + ]); + + $factory = new AdapterInterfaceFactory(); + $adapter = $factory($container, AdapterInterface::class); + + self::assertInstanceOf(Adapter::class, $adapter); + self::assertNull($adapter->getProfiler()); + } + + public function testInvokeCreatesAdapterWithDefaultResultSet(): void + { + $driverMock = $this->createMock(DriverInterface::class); + $platformMock = $this->createMock(PlatformInterface::class); + + $container = new ServiceManager([ + 'factories' => [ + DriverInterface::class => static fn() => $driverMock, + PlatformInterface::class => static fn() => $platformMock, + ], + ]); + $container->setService('config', [ + AdapterInterface::class => [ + 'driver' => DriverInterface::class, + ], + ]); + + $factory = new AdapterInterfaceFactory(); + $adapter = $factory($container, AdapterInterface::class); + + self::assertInstanceOf(ResultSet::class, $adapter->getQueryResultSetPrototype()); + } +} diff --git a/test/unit/Exception/ContainerExceptionTest.php b/test/unit/Exception/ContainerExceptionTest.php new file mode 100644 index 00000000..5356edcc --- /dev/null +++ b/test/unit/Exception/ContainerExceptionTest.php @@ -0,0 +1,32 @@ +getMessage()); + self::assertStringContainsString('Factory', $exception->getMessage()); + self::assertStringContainsString('reason', $exception->getMessage()); + } + + public function testImplementsContainerExceptionInterface(): void + { + $exception = ContainerException::forService('Svc', 'Factory', 'reason'); + + self::assertInstanceOf(ContainerExceptionInterface::class, $exception); + } +} diff --git a/test/unit/Metadata/Object/AbstractTableObjectTest.php b/test/unit/Metadata/Object/AbstractTableObjectTest.php index f6e41884..93644eb0 100644 --- a/test/unit/Metadata/Object/AbstractTableObjectTest.php +++ b/test/unit/Metadata/Object/AbstractTableObjectTest.php @@ -7,14 +7,14 @@ use PhpDb\Metadata\Object\AbstractTableObject; use PhpDb\Metadata\Object\ColumnObject; use PhpDb\Metadata\Object\ConstraintObject; +use PhpDbTest\Metadata\Object\TestAsset\ConcreteTableObject; use PHPUnit\Framework\TestCase; final class AbstractTableObjectTest extends TestCase { private function createConcreteTableObject(?string $name): AbstractTableObject { - return new class ($name) extends AbstractTableObject { - }; + return new ConcreteTableObject($name); } public function testConstructorWithName(): void diff --git a/test/unit/Metadata/Object/TestAsset/ConcreteTableObject.php b/test/unit/Metadata/Object/TestAsset/ConcreteTableObject.php new file mode 100644 index 00000000..6641cc19 --- /dev/null +++ b/test/unit/Metadata/Object/TestAsset/ConcreteTableObject.php @@ -0,0 +1,11 @@ +abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'table_names' => [ + 'def' => [ + 'users' => ['table_type' => 'BASE TABLE'], + ], + ], + 'columns' => [ + 'def' => [ + 'users' => [], + ], + ], + 'constraints' => [ + 'def' => [ + 'users' => [], + ], + ], + ]); + + $tables = $this->abstractSourceMock->getTables(null); + + self::assertCount(1, $tables); + self::assertInstanceOf(TableObject::class, $tables[0]); + } + + /** + * @throws ReflectionException + */ + public function testGetTableUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'table_names' => [ + 'def' => [ + 'users' => ['table_type' => 'BASE TABLE'], + ], + ], + 'columns' => [ + 'def' => [ + 'users' => [], + ], + ], + 'constraints' => [ + 'def' => [ + 'users' => [], + ], + ], + ]); + + $table = $this->abstractSourceMock->getTable('users', null); + + self::assertInstanceOf(TableObject::class, $table); + } + + /** + * @throws ReflectionException + */ + public function testGetViewNamesUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'table_names' => [ + 'def' => [ + 'v1' => ['table_type' => 'VIEW'], + ], + ], + ]); + + $names = $this->abstractSourceMock->getViewNames(null); + + self::assertSame(['v1'], $names); + } + + /** + * @throws ReflectionException + */ + public function testGetViewsUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'table_names' => [ + 'def' => [ + 'v1' => [ + 'table_type' => 'VIEW', + 'view_definition' => 'SELECT 1', + 'check_option' => null, + 'is_updatable' => true, + ], + ], + ], + 'columns' => ['def' => ['v1' => []]], + 'constraints' => ['def' => ['v1' => []]], + ]); + + $views = $this->abstractSourceMock->getViews(null); + + self::assertCount(1, $views); + } + + /** + * @throws ReflectionException + */ + public function testGetViewUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'table_names' => [ + 'def' => [ + 'v1' => [ + 'table_type' => 'VIEW', + 'view_definition' => 'SELECT 1', + 'check_option' => null, + 'is_updatable' => false, + ], + ], + ], + 'columns' => ['def' => ['v1' => []]], + 'constraints' => ['def' => ['v1' => []]], + ]); + + $view = $this->abstractSourceMock->getView('v1', null); + + self::assertInstanceOf(ViewObject::class, $view); + } + + /** + * @throws ReflectionException + */ + public function testGetColumnNamesUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'columns' => [ + 'def' => [ + 'users' => [ + 'id' => [], + 'name' => [], + ], + ], + ], + ]); + + $names = $this->abstractSourceMock->getColumnNames('users', null); + + self::assertSame(['id', 'name'], $names); + } + + /** + * @throws ReflectionException + */ + public function testGetColumnsUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'columns' => [ + 'def' => [ + 'users' => [ + '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' => [], + ], + ], + ], + ], + ]); + + $columns = $this->abstractSourceMock->getColumns('users', null); + + self::assertCount(1, $columns); + self::assertInstanceOf(ColumnObject::class, $columns[0]); + } + + /** + * @throws ReflectionException + */ + public function testGetColumnUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'columns' => [ + 'def' => [ + 'users' => [ + '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' => [], + ], + ], + ], + ], + ]); + + $column = $this->abstractSourceMock->getColumn('id', 'users', null); + + self::assertInstanceOf(ColumnObject::class, $column); + } + + /** + * @throws ReflectionException + */ + public function testGetConstraintsUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'constraints' => [ + 'def' => [ + 'users' => [ + 'pk' => [ + 'constraint_type' => 'PRIMARY KEY', + 'columns' => ['id'], + ], + ], + ], + ], + ]); + + $constraints = $this->abstractSourceMock->getConstraints('users', null); + + self::assertCount(1, $constraints); + } + + /** + * @throws ReflectionException + */ + public function testGetConstraintUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'constraints' => [ + 'def' => [ + 'users' => [ + 'pk' => [ + 'constraint_type' => 'PRIMARY KEY', + 'columns' => ['id'], + ], + ], + ], + ], + ]); + + $constraint = $this->abstractSourceMock->getConstraint('pk', 'users', null); + + self::assertInstanceOf(ConstraintObject::class, $constraint); + } + + /** + * @throws ReflectionException + */ + public function testGetConstraintKeysUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'constraint_references' => [ + 'def' => [], + ], + 'constraint_keys' => [ + 'def' => [ + [ + 'table_name' => 'users', + 'constraint_name' => 'pk', + 'column_name' => 'id', + 'ordinal_position' => 1, + ], + ], + ], + ]); + + $keys = $this->abstractSourceMock->getConstraintKeys('pk', 'users', null); + + self::assertCount(1, $keys); + self::assertInstanceOf(ConstraintKeyObject::class, $keys[0]); + } + + /** + * @throws ReflectionException + */ + public function testGetTriggerNamesUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'triggers' => [ + 'def' => [ + 'trig1' => [], + ], + ], + ]); + + $names = $this->abstractSourceMock->getTriggerNames(null); + + self::assertSame(['trig1'], $names); + } + + /** + * @throws ReflectionException + */ + public function testGetTriggersUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'triggers' => [ + 'def' => [ + 'trig1' => [ + 'event_manipulation' => 'INSERT', + 'event_object_catalog' => 'cat', + 'event_object_schema' => 'def', + 'event_object_table' => 'users', + 'action_order' => '1', + 'action_condition' => null, + 'action_statement' => 'BEGIN END', + 'action_orientation' => 'ROW', + 'action_timing' => 'BEFORE', + 'action_reference_old_table' => null, + 'action_reference_new_table' => null, + 'action_reference_old_row' => 'OLD', + 'action_reference_new_row' => 'NEW', + 'created' => null, + ], + ], + ], + ]); + + $triggers = $this->abstractSourceMock->getTriggers(null); + + self::assertCount(1, $triggers); + self::assertInstanceOf(TriggerObject::class, $triggers[0]); + } + + /** + * @throws ReflectionException + */ + public function testGetTriggerUsesDefaultSchemaWhenNull(): void + { + $refProp = new ReflectionProperty($this->abstractSourceMock, 'defaultSchema'); + $refProp->setValue($this->abstractSourceMock, 'def'); + + $this->setMockData([ + 'triggers' => [ + 'def' => [ + 'trig1' => [ + 'event_manipulation' => 'INSERT', + 'event_object_catalog' => 'cat', + 'event_object_schema' => 'def', + 'event_object_table' => 'users', + 'action_order' => '1', + 'action_condition' => null, + 'action_statement' => 'BEGIN END', + 'action_orientation' => 'ROW', + 'action_timing' => 'BEFORE', + 'action_reference_old_table' => null, + 'action_reference_new_table' => null, + 'action_reference_old_row' => 'OLD', + 'action_reference_new_row' => 'NEW', + 'created' => null, + ], + ], + ], + ]); + + $trigger = $this->abstractSourceMock->getTrigger('trig1', null); + + self::assertInstanceOf(TriggerObject::class, $trigger); + self::assertSame('trig1', $trigger->getName()); + } + + /** + * @throws ReflectionException + */ + public function testLoadTableNameDataCallsPrepareDataHierarchy(): void + { + $method = new ReflectionMethod($this->abstractSourceMock, 'loadTableNameData'); + $method->invoke($this->abstractSourceMock, 'test_schema'); + + $data = $this->getMockData(); + self::assertArrayHasKey('table_names', $data); + self::assertArrayHasKey('test_schema', $data['table_names']); + } + + /** + * @throws ReflectionException + */ + public function testLoadColumnDataCallsPrepareDataHierarchy(): void + { + $method = new ReflectionMethod($this->abstractSourceMock, 'loadColumnData'); + $method->invoke($this->abstractSourceMock, 'users', 'test_schema'); + + $data = $this->getMockData(); + self::assertArrayHasKey('columns', $data); + self::assertArrayHasKey('test_schema', $data['columns']); + self::assertArrayHasKey('users', $data['columns']['test_schema']); + } + + /** + * @throws ReflectionException + */ + public function testLoadConstraintDataCallsPrepareDataHierarchy(): void + { + $method = new ReflectionMethod($this->abstractSourceMock, 'loadConstraintData'); + $method->invoke($this->abstractSourceMock, 'users', 'test_schema'); + + $data = $this->getMockData(); + self::assertArrayHasKey('constraints', $data); + self::assertArrayHasKey('test_schema', $data['constraints']); + } + + /** + * @throws ReflectionException + */ + public function testLoadConstraintDataKeysCallsPrepareDataHierarchy(): void + { + $method = new ReflectionMethod($this->abstractSourceMock, 'loadConstraintDataKeys'); + $method->invoke($this->abstractSourceMock, 'test_schema'); + + $data = $this->getMockData(); + self::assertArrayHasKey('constraint_keys', $data); + self::assertArrayHasKey('test_schema', $data['constraint_keys']); + } + + /** + * @throws ReflectionException + */ + public function testLoadConstraintReferencesCallsPrepareDataHierarchy(): void + { + $method = new ReflectionMethod($this->abstractSourceMock, 'loadConstraintReferences'); + $method->invoke($this->abstractSourceMock, 'users', 'test_schema'); + + $data = $this->getMockData(); + self::assertArrayHasKey('constraint_references', $data); + self::assertArrayHasKey('test_schema', $data['constraint_references']); + } + + /** + * @throws ReflectionException + */ + public function testLoadTriggerDataCallsPrepareDataHierarchy(): void + { + $method = new ReflectionMethod($this->abstractSourceMock, 'loadTriggerData'); + $method->invoke($this->abstractSourceMock, 'test_schema'); + + $data = $this->getMockData(); + self::assertArrayHasKey('triggers', $data); + self::assertArrayHasKey('test_schema', $data['triggers']); + } + + public function testGetColumnNamesThrowsWhenLoadColumnDataDoesNotPopulate(): void + { + $adapter = $this->createMockForIntersectionOfInterfaces([ + AdapterInterface::class, + SchemaAwareInterface::class, + ]); + $adapter->method('getCurrentSchema')->willReturn('public'); + + $source = new IncompleteSource($adapter); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('"nonexistent" does not exist'); + $source->getColumnNames('nonexistent', 'public'); + } } diff --git a/test/unit/Metadata/Source/TestAsset/IncompleteSource.php b/test/unit/Metadata/Source/TestAsset/IncompleteSource.php new file mode 100644 index 00000000..9d856bd3 --- /dev/null +++ b/test/unit/Metadata/Source/TestAsset/IncompleteSource.php @@ -0,0 +1,22 @@ +createResultSetMock(); + $resultSet->initialize(new ArrayIterator([ + ['id' => 1, 'name' => 'one'], + ['id' => 2, 'name' => 'two'], + ])); + $resultSet->buffer(); + + $firstPass = []; + foreach ($resultSet as $row) { + $firstPass[] = $row; + } + + $resultSet->rewind(); + + $secondPass = []; + foreach ($resultSet as $row) { + $secondPass[] = $row; + } + + self::assertEquals($firstPass, $secondPass); + } + + public function testToArrayConvertsArrayObjectsViaGetArrayCopy(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize([ + new ArrayObject(['id' => 1, 'name' => 'one']), + ]); + + $result = $resultSet->toArray(); + + self::assertSame([['id' => 1, 'name' => 'one']], $result); + } + + public function testGetFieldCountReturnsZeroForEmptyIterator(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([])); + + self::assertSame(0, $resultSet->getFieldCount()); + } + + public function testInitializeWithBufferedResultInterface(): void + { + $result = $this->createMock(ResultInterface::class); + $result->method('isBuffered')->willReturn(true); + $result->method('getFieldCount')->willReturn(2); + + $resultSet = $this->createResultSetMock(); + $resultSet->initialize($result); + + self::assertTrue($resultSet->isBuffered()); + } + + public function testCountReturnsNullForUncountableDataSource(): void + { + $resultSet = $this->createResultSetMock(); + $iterator = new NoRewindIterator(new ArrayIterator([['id' => 1]])); + $resultSet->initialize($iterator); + + self::assertNull($resultSet->count()); + } + + public function testValidReturnsFalseAfterLastElement(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([ + ['id' => 1], + ])); + + self::assertTrue($resultSet->valid()); + $resultSet->next(); + self::assertFalse($resultSet->valid()); + } + /** * Test multiple iterations with buffer * @@ -378,4 +459,135 @@ public function testMultipleRewindBufferIterations(): void $data = $resultSet->current(); self::assertEquals(3, $data['id']); } + + /** + * @throws Exception + */ + public function testInitializeResetsBufferWhenAlreadyBuffered(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([['id' => 1]])); + $resultSet->buffer(); + + $resultSet->initialize(new ArrayIterator([['id' => 2]])); + + self::assertSame(2, $resultSet->current()['id']); + } + + /** + * @throws Exception + */ + public function testInitializeWithResultInterfaceRewindsWhenBuffered(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([['id' => 1]])); + $resultSet->buffer(); + + $result = $this->createMock(ResultInterface::class); + $result->method('getFieldCount')->willReturn(2); + $result->method('isBuffered')->willReturn(false); + $result->expects(self::once())->method('rewind'); + + $resultSet->initialize($result); + } + + /** + * @throws Exception + */ + public function testInitializeWithIteratorAggregate(): void + { + $resultSet = $this->createResultSetMock(); + $aggregate = new class implements IteratorAggregate { + public function getIterator(): ArrayIterator + { + return new ArrayIterator([['id' => 1], ['id' => 2]]); + } + }; + + $resultSet->initialize($aggregate); + + self::assertSame(1, $resultSet->current()['id']); + } + + public function testGetFieldCountReturnsCachedValue(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize([['a' => 1, 'b' => 2]]); + + $first = $resultSet->getFieldCount(); + $second = $resultSet->getFieldCount(); + + self::assertSame(2, $first); + self::assertSame($first, $second); + } + + public function testGetFieldCountReturnsZeroWithNoDataSource(): void + { + $resultSet = $this->createResultSetMock(); + + self::assertSame(0, $resultSet->getFieldCount()); + } + + public function testGetFieldCountWithCountableRow(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([new ArrayObject(['a' => 1, 'b' => 2, 'c' => 3])])); + + self::assertSame(3, $resultSet->getFieldCount()); + } + + public function testValidWithNonIteratorDataSource(): void + { + $resultSet = $this->createResultSetMock(); + $aggregate = new class implements IteratorAggregate { + public function getIterator(): ArrayIterator + { + return new ArrayIterator([['id' => 1]]); + } + }; + + $resultSet->initialize($aggregate); + $resultSet->rewind(); + + self::assertTrue($resultSet->valid()); + } + + public function testRewindWithNonIteratorDataSource(): void + { + $resultSet = $this->createResultSetMock(); + $aggregate = new class implements IteratorAggregate { + public function getIterator(): ArrayIterator + { + return new ArrayIterator([['id' => 1], ['id' => 2]]); + } + }; + + $resultSet->initialize($aggregate); + $resultSet->next(); + $resultSet->rewind(); + + self::assertSame(0, $resultSet->key()); + } + + public function testCountReturnsCachedResult(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize([['id' => 1], ['id' => 2]]); + + $first = $resultSet->count(); + $second = $resultSet->count(); + + self::assertSame(2, $first); + self::assertSame($first, $second); + } + + public function testToArrayThrowsOnNonCastableRows(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([new \stdClass()])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('cannot be cast to an array'); + $resultSet->toArray(); + } } diff --git a/test/unit/ResultSet/HydratingResultSetTest.php b/test/unit/ResultSet/HydratingResultSetTest.php index f3f1c792..06f80af9 100644 --- a/test/unit/ResultSet/HydratingResultSetTest.php +++ b/test/unit/ResultSet/HydratingResultSetTest.php @@ -4,12 +4,15 @@ namespace PhpDbTest\ResultSet; +use ArrayIterator; +use ArrayObject; use Exception; use Laminas\Hydrator\ArraySerializableHydrator; use Laminas\Hydrator\ClassMethodsHydrator; use Override; use PhpDb\ResultSet\HydratingResultSet; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use stdClass; @@ -19,6 +22,10 @@ #[CoversMethod(HydratingResultSet::class, 'getHydrator')] #[CoversMethod(HydratingResultSet::class, 'current')] #[CoversMethod(HydratingResultSet::class, 'toArray')] +#[CoversMethod(HydratingResultSet::class, '__construct')] +#[CoversMethod(HydratingResultSet::class, 'setRowPrototype')] +#[CoversMethod(HydratingResultSet::class, 'getRowPrototype')] +#[Group('unit')] final class HydratingResultSetTest extends TestCase { private string $arraySerializableHydratorClass; @@ -138,4 +145,72 @@ public function testToArray(): void $obj = $hydratingRs->toArray(); self::assertIsArray($obj); } + + public function testConstructorDefaultsToArraySerializableHydrator(): void + { + $hydratingRs = new HydratingResultSet(); + + self::assertInstanceOf(ArraySerializableHydrator::class, $hydratingRs->getHydrator()); + } + + public function testSetRowPrototypeStoresPrototype(): void + { + $hydratingRs = new HydratingResultSet(); + $prototype = new stdClass(); + + $result = $hydratingRs->setRowPrototype($prototype); + + self::assertSame($hydratingRs, $result); + self::assertSame($prototype, $hydratingRs->getRowPrototype()); + } + + public function testGetRowPrototypeReturnsDefaultArrayObject(): void + { + $hydratingRs = new HydratingResultSet(); + + self::assertInstanceOf(ArrayObject::class, $hydratingRs->getRowPrototype()); + } + + public function testCurrentWithBufferReturnsBufferedObject(): void + { + $hydratingRs = new HydratingResultSet(); + $hydratingRs->initialize(new ArrayIterator([ + ['id' => 1, 'name' => 'one'], + ['id' => 2, 'name' => 'two'], + ])); + $hydratingRs->buffer(); + + $first = $hydratingRs->current(); + $hydratingRs->rewind(); + $buffered = $hydratingRs->current(); + + self::assertSame($first, $buffered); + } + + public function testToArrayUsesHydratorExtract(): void + { + $hydratingRs = new HydratingResultSet(); + $hydratingRs->initialize([ + ['id' => 1, 'name' => 'one'], + ]); + + $result = $hydratingRs->toArray(); + + self::assertCount(1, $result); + self::assertArrayHasKey('id', $result[0]); + self::assertSame(1, $result[0]['id']); + } + + public function testCurrentDisablesBufferingImplicitly(): void + { + $hydratingRs = new HydratingResultSet(); + $hydratingRs->initialize(new \ArrayIterator([ + ['id' => 1], + ])); + + $hydratingRs->current(); + + $this->expectException(\PhpDb\ResultSet\Exception\RuntimeException::class); + $hydratingRs->buffer(); + } } diff --git a/test/unit/ResultSet/ResultSetIntegrationTest.php b/test/unit/ResultSet/ResultSetIntegrationTest.php index 4c417018..4987fb20 100644 --- a/test/unit/ResultSet/ResultSetIntegrationTest.php +++ b/test/unit/ResultSet/ResultSetIntegrationTest.php @@ -14,6 +14,7 @@ use PhpDb\ResultSet\ResultSetReturnType; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use Random\RandomException; @@ -27,6 +28,11 @@ #[CoversMethod(AbstractResultSet::class, 'current')] #[CoversMethod(AbstractResultSet::class, 'buffer')] +#[CoversMethod(ResultSet::class, 'current')] +#[CoversMethod(ResultSet::class, 'getReturnType')] +#[CoversMethod(ResultSet::class, '__construct')] +#[CoversMethod(ResultSet::class, 'getArrayObjectPrototype')] +#[Group('unit')] final class ResultSetIntegrationTest extends TestCase { protected ResultSet $resultSet; @@ -320,4 +326,56 @@ public function testCurrentReturnsNullForNonExistingValues(): void // Verify current() returns null when data source returns non-array value self::assertNull($this->resultSet->current()); } + + public function testCurrentReturnsArrayObjectWhenReturnTypeIsArrayObject(): void + { + $resultSet = new ResultSet(ResultSetReturnType::ArrayObject); + $resultSet->initialize([['id' => 1, 'name' => 'one']]); + + $current = $resultSet->current(); + + self::assertInstanceOf(ArrayObject::class, $current); + self::assertSame(1, $current['id']); + } + + public function testCurrentReturnsArrayWhenReturnTypeIsArray(): void + { + $resultSet = new ResultSet(ResultSetReturnType::Array); + $resultSet->initialize([['id' => 1, 'name' => 'one']]); + + $current = $resultSet->current(); + + self::assertIsArray($current); + self::assertSame(1, $current['id']); + } + + public function testCurrentClonesRowPrototypeOnEachCall(): void + { + $resultSet = new ResultSet(ResultSetReturnType::ArrayObject); + $resultSet->initialize([ + ['id' => 1, 'name' => 'one'], + ['id' => 2, 'name' => 'two'], + ]); + + $first = $resultSet->current(); + $resultSet->next(); + $second = $resultSet->current(); + + self::assertNotSame($first, $second); + } + + public function testGetReturnTypeReturnsArrayWhenSetToArray(): void + { + $resultSet = new ResultSet(ResultSetReturnType::Array); + + self::assertSame(ResultSetReturnType::Array, $resultSet->getReturnType()); + } + + public function testGetArrayObjectPrototypeDelegatesToGetRowPrototype(): void + { + self::assertSame( + $this->resultSet->getRowPrototype(), + $this->resultSet->getArrayObjectPrototype() + ); + } } diff --git a/test/unit/RowGateway/Feature/FeatureSetTest.php b/test/unit/RowGateway/Feature/FeatureSetTest.php index b59907b4..5507a126 100644 --- a/test/unit/RowGateway/Feature/FeatureSetTest.php +++ b/test/unit/RowGateway/Feature/FeatureSetTest.php @@ -7,6 +7,7 @@ use PhpDb\RowGateway\AbstractRowGateway; use PhpDb\RowGateway\Feature\AbstractFeature; use PhpDb\RowGateway\Feature\FeatureSet; +use PhpDbTest\RowGateway\Feature\TestAsset\TestRowGatewayFeature; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -104,76 +105,27 @@ public function testAddFeatureCallsSetRowGatewayWhenRowGatewayIsSet(): void public function testApplyCallsMethodOnFeatures(): void { - $called = false; - $receivedArgs = []; - - $feature = new class ($called, $receivedArgs) extends AbstractFeature { - /** @var bool @phpstan-ignore property.onlyWritten */ - private $called; - /** @var array @phpstan-ignore property.onlyWritten */ - private $receivedArgs; - - public function __construct(bool &$called, array &$receivedArgs) - { - $this->called = &$called; - $this->receivedArgs = &$receivedArgs; - } - - public function preInitialize(string $arg1, string $arg2): void - { - $this->called = true; - $this->receivedArgs = [$arg1, $arg2]; - } - }; + $feature = new TestRowGatewayFeature(); $featureSet = new FeatureSet([$feature]); $featureSet->apply('preInitialize', ['arg1', 'arg2']); - self::assertTrue($called); - self::assertEquals(['arg1', 'arg2'], $receivedArgs); + self::assertTrue($feature->called); + self::assertEquals(['arg1', 'arg2'], $feature->receivedArgs); } public function testApplyHaltsWhenFeatureReturnsHalt(): void { - $feature1Called = false; - $feature2Called = false; - - $feature1 = new class ($feature1Called) extends AbstractFeature { - /** @var bool @phpstan-ignore property.onlyWritten */ - private $called; - - public function __construct(bool &$called) - { - $this->called = &$called; - } - - public function preInitialize(): string - { - $this->called = true; - return FeatureSet::APPLY_HALT; - } - }; - - $feature2 = new class ($feature2Called) extends AbstractFeature { - /** @var bool @phpstan-ignore property.onlyWritten */ - private $called; - - public function __construct(bool &$called) - { - $this->called = &$called; - } + $feature1 = new TestRowGatewayFeature(); + $feature1->returnValue = FeatureSet::APPLY_HALT; - public function preInitialize(): void - { - $this->called = true; - } - }; + $feature2 = new TestRowGatewayFeature(); $featureSet = new FeatureSet([$feature1, $feature2]); $featureSet->apply('preInitialize', []); - self::assertTrue($feature1Called); - self::assertFalse($feature2Called); + self::assertTrue($feature1->called); + self::assertFalse($feature2->called); } public function testApplySkipsFeatureWithoutMethod(): void diff --git a/test/unit/RowGateway/Feature/TestAsset/TestRowGatewayFeature.php b/test/unit/RowGateway/Feature/TestAsset/TestRowGatewayFeature.php new file mode 100644 index 00000000..6a41fc2d --- /dev/null +++ b/test/unit/RowGateway/Feature/TestAsset/TestRowGatewayFeature.php @@ -0,0 +1,29 @@ + */ + public array $receivedArgs = []; + + public ?string $returnValue = null; + + public function preInitialize(string ...$args): mixed + { + $this->called = true; + $this->receivedArgs = $args; + return $this->returnValue; + } + + public function postInitialize(): void + { + $this->called = true; + } +} diff --git a/test/unit/Sql/AbstractSqlTest.php b/test/unit/Sql/AbstractSqlTest.php index 005df76f..e07aa106 100644 --- a/test/unit/Sql/AbstractSqlTest.php +++ b/test/unit/Sql/AbstractSqlTest.php @@ -9,7 +9,9 @@ use PhpDb\Adapter\ParameterContainer; use PhpDb\Adapter\StatementContainer; use PhpDb\Sql\AbstractSql; +use PhpDb\Sql\Argument; use PhpDb\Sql\Argument\Identifier; +use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\Expression; use PhpDb\Sql\ExpressionInterface; use PhpDb\Sql\Predicate; @@ -39,12 +41,13 @@ #[CoversMethod(AbstractSql::class, 'buildSqlString')] #[CoversMethod(AbstractSql::class, 'renderTable')] #[CoversMethod(AbstractSql::class, 'processExpression')] -#[CoversMethod(AbstractSql::class, 'processExpressionValue')] #[CoversMethod(AbstractSql::class, 'processExpressionOrSelect')] #[CoversMethod(AbstractSql::class, 'processExpressionParameterName')] #[CoversMethod(AbstractSql::class, 'createSqlFromSpecificationAndParameters')] #[CoversMethod(AbstractSql::class, 'processSubSelect')] #[CoversMethod(AbstractSql::class, 'processJoin')] +#[CoversMethod(AbstractSql::class, 'processIdentifiersArgument')] +#[CoversMethod(AbstractSql::class, 'flattenExpressionValues')] #[CoversMethod(AbstractSql::class, 'resolveColumnValue')] #[CoversMethod(AbstractSql::class, 'resolveTable')] #[CoversMethod(AbstractSql::class, 'localizeVariables')] @@ -351,6 +354,63 @@ public function testProcessSubSelectWithoutParameterContainer(): void self::assertStringContainsString('SELECT', $result); } + /** + * @throws ReflectionException + */ + public function testProcessExpressionWithValuesArgument(): void + { + $expression = new Expression( + '? IN (?, ?, ?)', + [ + new Argument\Identifier('id'), + new Argument\Value(1), + new Argument\Value(2), + new Argument\Value(3), + ] + ); + + $sqlAndParams = $this->invokeProcessExpressionMethod($expression); + + self::assertStringContainsString("'1'", $sqlAndParams); + self::assertStringContainsString("'2'", $sqlAndParams); + self::assertStringContainsString("'3'", $sqlAndParams); + self::assertStringContainsString('"id"', $sqlAndParams); + } + + /** + * @throws ReflectionException + */ + public function testProcessExpressionWithIdentifiersArgument(): void + { + $expression = new Expression('? IN (SELECT col1, col2 FROM bar)', [ + Argument::identifiers(['col1', 'col2']), + ]); + + $sqlAndParams = $this->invokeProcessExpressionMethod($expression); + + self::assertStringContainsString('"col1"', $sqlAndParams); + self::assertStringContainsString('"col2"', $sqlAndParams); + } + + /** + * @throws ReflectionException + */ + public function testCreateSqlFromSpecificationThrowsOnParameterCountMismatch(): void + { + $method = new ReflectionMethod($this->abstractSql, 'createSqlFromSpecificationAndParameters'); + + $specifications = [ + 'SELECT %1$s FROM %2$s' => [ + [1 => '%1$s', 'combinedby' => ', '], + null, + ], + ]; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('A number of parameters was found that is not supported by this specification'); + $method->invoke($this->abstractSql, $specifications, ['col1', 'table', 'extra']); + } + /** * @throws ReflectionException */ @@ -369,4 +429,329 @@ protected function invokeProcessExpressionMethod( $namedParameterPrefix ); } + + /** + * @throws ReflectionException + */ + public function testProcessJoinWithArrayAlias(): void + { + $join = new \PhpDb\Sql\Join(); + $join->join(['b' => 'bar'], 'foo.id = b.foo_id'); + + $method = new ReflectionMethod($this->abstractSql, 'processJoin'); + $result = $method->invoke( + $this->abstractSql, + $join, + new TrustingSql92Platform(), + null, + null + ); + + self::assertNotNull($result); + self::assertStringContainsString('AS', $result[0][0][1]); + } + + /** + * @throws ReflectionException + */ + public function testProcessJoinWithTableIdentifier(): void + { + $join = new \PhpDb\Sql\Join(); + $join->join(new \PhpDb\Sql\TableIdentifier('bar', 'myschema'), 'foo.id = bar.foo_id'); + + $method = new ReflectionMethod($this->abstractSql, 'processJoin'); + $result = $method->invoke( + $this->abstractSql, + $join, + new TrustingSql92Platform(), + null, + null + ); + + self::assertNotNull($result); + self::assertStringContainsString('"myschema"', $result[0][0][1]); + self::assertStringContainsString('"bar"', $result[0][0][1]); + } + + /** + * @throws ReflectionException + */ + public function testProcessJoinWithPredicateExpressionOnClause(): void + { + $join = new \PhpDb\Sql\Join(); + $join->join('bar', new Predicate\Expression('foo.id = bar.foo_id AND bar.active = 1')); + + $method = new ReflectionMethod($this->abstractSql, 'processJoin'); + $result = $method->invoke( + $this->abstractSql, + $join, + new TrustingSql92Platform(), + null, + null + ); + + self::assertNotNull($result); + self::assertStringContainsString('foo.id = bar.foo_id', $result[0][0][2]); + } + + /** + * @throws ReflectionException + */ + public function testProcessJoinReturnsNullWhenEmpty(): void + { + $method = new ReflectionMethod($this->abstractSql, 'processJoin'); + $result = $method->invoke( + $this->abstractSql, + null, + new TrustingSql92Platform(), + null, + null + ); + + self::assertNull($result); + } + + /** + * @throws ReflectionException + */ + public function testRenderTableWithAlias(): void + { + $method = new ReflectionMethod($this->abstractSql, 'renderTable'); + $result = $method->invoke($this->abstractSql, '"foo"', '"f"'); + + self::assertSame('"foo" AS "f"', $result); + } + + /** + * @throws ReflectionException + */ + public function testProcessJoinWithExpressionNameViaArray(): void + { + $join = new \PhpDb\Sql\Join(); + $join->join(['x' => new Expression('LATERAL(SELECT 1)')], 'true'); + + $method = new ReflectionMethod($this->abstractSql, 'processJoin'); + $result = $method->invoke( + $this->abstractSql, + $join, + new TrustingSql92Platform(), + null, + null + ); + + self::assertStringContainsString('LATERAL(SELECT 1)', $result[0][0][1]); + } + + /** + * @throws ReflectionException + */ + public function testProcessJoinWithSelectSubqueryViaArray(): void + { + $subselect = new Select('bar'); + $join = new \PhpDb\Sql\Join(); + $join->join(['b' => $subselect], 'foo.id = b.foo_id'); + + $method = new ReflectionMethod($this->abstractSql, 'processJoin'); + $result = $method->invoke( + $this->abstractSql, + $join, + new TrustingSql92Platform(), + null, + null + ); + + self::assertStringContainsString('SELECT', $result[0][0][1]); + self::assertStringContainsString('AS', $result[0][0][1]); + } + + /** + * @throws ReflectionException + */ + public function testCreateSqlFromSpecWithCombinedByScalarParam(): void + { + $method = new ReflectionMethod($this->abstractSql, 'createSqlFromSpecificationAndParameters'); + + $spec = [ + 'SELECT %1$s FROM %2$s' => [ + [1 => '%1$s', 'combinedby' => ', '], + null, + ], + ]; + $params = [['col1'], 'table1']; + + $result = $method->invoke($this->abstractSql, $spec, $params); + + self::assertSame('SELECT col1 FROM table1', $result); + } + + /** + * @throws ReflectionException + */ + public function testCreateSqlFromSpecWithCombinedByThrowsOnUnsupportedCount(): void + { + $method = new ReflectionMethod($this->abstractSql, 'createSqlFromSpecificationAndParameters'); + + $spec = [ + 'SELECT %1$s FROM %2$s' => [ + [1 => '%1$s', 'combinedby' => ', '], + null, + ], + ]; + $params = [[['a', 'b']], 'table1']; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('A number of parameters (2)'); + $method->invoke($this->abstractSql, $spec, $params); + } + + /** + * @throws ReflectionException + */ + public function testCreateSqlFromSpecWithNonCombinedByParam(): void + { + $method = new ReflectionMethod($this->abstractSql, 'createSqlFromSpecificationAndParameters'); + + $spec = [ + 'FROM %1$s' => [ + [1 => '%1$s'], + ], + ]; + $params = [['my_table']]; + + $result = $method->invoke($this->abstractSql, $spec, $params); + + self::assertSame('FROM my_table', $result); + } + + /** + * @throws ReflectionException + */ + public function testCreateSqlFromSpecNonCombinedByThrowsOnUnsupportedCount(): void + { + $method = new ReflectionMethod($this->abstractSql, 'createSqlFromSpecificationAndParameters'); + + $spec = [ + 'FROM %1$s' => [ + [1 => '%1$s'], + ], + ]; + $params = [['a', 'b']]; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('A number of parameters (2)'); + $method->invoke($this->abstractSql, $spec, $params); + } + + public function testProcessExpressionThrowsOnUnknownArgumentType(): void + { + $unknownArg = new class implements \PhpDb\Sql\ArgumentInterface { + public function getType(): \PhpDb\Sql\ArgumentType + { + return \PhpDb\Sql\ArgumentType::Value; + } + + public function getValue(): string + { + return 'test'; + } + + public function getSpecification(): string + { + return '%s'; + } + }; + + $expression = new Expression('?', [$unknownArg]); + + $this->expectException(\PhpDb\Sql\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown argument type'); + $this->invokeProcessExpressionMethod($expression); + } + + public function testResolveColumnValueWithNamedParameterPrefix(): void + { + $select = new Select('users'); + $select->columns(['id']); + $select->where(new Predicate\In('status', [1, 2])); + + $mockDriver = $this->getMockBuilder(DriverInterface::class)->getMock(); + $mockDriver->method('formatParameterName') + ->willReturnCallback(fn(string $name): string => ':' . $name); + + $parameterContainer = new ParameterContainer(); + $mockStatement = $this->createMock(\PhpDb\Adapter\Driver\StatementInterface::class); + $mockStatement->method('getParameterContainer')->willReturn($parameterContainer); + $mockStatement->method('setSql')->willReturnSelf(); + + $adapter = $this->getMockBuilder(\PhpDb\Adapter\Adapter::class) + ->setConstructorArgs([$mockDriver, new TrustingSql92Platform()]) + ->getMock(); + $adapter->method('getDriver')->willReturn($mockDriver); + $adapter->method('getPlatform')->willReturn(new TrustingSql92Platform()); + + $select->prepareStatement($adapter, $mockStatement); + + self::assertGreaterThanOrEqual(2, $parameterContainer->count()); + } + + public function testLocalizeVariablesCopiesSubjectProperties(): void + { + $decorator = new \PhpDbTest\TestAsset\SelectDecorator(); + $select = new Select('users'); + $select->columns(['id', 'name']); + $decorator->setSubject($select); + + $sql = $decorator->getSqlString(new TrustingSql92Platform()); + + self::assertStringContainsString('"users"', $sql); + self::assertStringContainsString('"id"', $sql); + } + + public function testProcessSubSelectUsesDecoratorWhenPlatformDecorator(): void + { + $decorator = new \PhpDbTest\TestAsset\SelectDecorator(); + $outer = new Select('foo'); + $outer->where(['x' => new Select('bar')]); + + $decorator->setSubject($outer); + + $sql = $decorator->getSqlString(new TrustingSql92Platform()); + + self::assertStringContainsString('SELECT "bar"', $sql); + self::assertStringContainsString('SELECT "foo"', $sql); + } + + public function testFlattenExpressionValuesViaInPredicate(): void + { + $select = new Select('users'); + $select->where(new Predicate\In('id', [1, 2, 3])); + + $sql = $select->getSqlString(new TrustingSql92Platform()); + + self::assertStringContainsString("\"id\" IN ('1', '2', '3')", $sql); + } + + public function testFlattenExpressionValuesViaInPredicateWithParameterContainer(): void + { + $select = new Select('users'); + $select->where(new Predicate\In('id', [1, 2, 3])); + + $mockDriver = $this->getMockBuilder(DriverInterface::class)->getMock(); + $mockDriver->method('formatParameterName') + ->willReturnCallback(fn(string $name): string => ':' . $name); + + $parameterContainer = new ParameterContainer(); + $mockStatement = $this->createMock(\PhpDb\Adapter\Driver\StatementInterface::class); + $mockStatement->method('getParameterContainer')->willReturn($parameterContainer); + + $adapter = $this->getMockBuilder(\PhpDb\Adapter\Adapter::class) + ->setConstructorArgs([$mockDriver, new TrustingSql92Platform()]) + ->getMock(); + $adapter->method('getDriver')->willReturn($mockDriver); + $adapter->method('getPlatform')->willReturn(new TrustingSql92Platform()); + + $select->prepareStatement($adapter, $mockStatement); + + self::assertSame(3, $parameterContainer->count()); + } } diff --git a/test/unit/Sql/Argument/LiteralTest.php b/test/unit/Sql/Argument/LiteralTest.php new file mode 100644 index 00000000..784918cb --- /dev/null +++ b/test/unit/Sql/Argument/LiteralTest.php @@ -0,0 +1,40 @@ +getType()); + } + + public function testGetValueReturnsLiteralString(): void + { + $literal = new Literal('NOW()'); + + self::assertSame('NOW()', $literal->getValue()); + } + + public function testGetSpecificationReturnsPlaceholder(): void + { + $literal = new Literal('test'); + + self::assertSame('%s', $literal->getSpecification()); + } +} diff --git a/test/unit/Sql/ArgumentTest.php b/test/unit/Sql/ArgumentTest.php index 40c847b8..1d94c9f2 100644 --- a/test/unit/Sql/ArgumentTest.php +++ b/test/unit/Sql/ArgumentTest.php @@ -16,16 +16,10 @@ use PHPUnit\Framework\TestCase; use TypeError; -#[CoversMethod(Argument::class, '__construct')] -#[CoversMethod(Argument::class, 'setType')] -#[CoversMethod(Argument::class, 'getType')] -#[CoversMethod(Argument::class, 'setValue')] -#[CoversMethod(Argument::class, 'getValue')] -#[CoversMethod(Argument::class, 'getValueAsString')] -#[CoversMethod(Argument::class, 'getSpecification')] #[CoversMethod(Argument::class, 'value')] #[CoversMethod(Argument::class, 'identifier')] #[CoversMethod(Argument::class, 'literal')] +#[CoversMethod(Argument::class, 'select')] final class ArgumentTest extends TestCase { public function testConstructorWithSimpleValue(): void @@ -128,4 +122,13 @@ public function testConstructorWithFloatValue(): void self::assertEquals(3.14, $argument->getValue()); self::assertEquals(ArgumentType::Value, $argument->getType()); } + + public function testStaticSelectMethodCreatesSelectArgument(): void + { + $select = new Select(); + $argument = Argument::select($select); + + self::assertSame($select, $argument->getValue()); + self::assertEquals(ArgumentType::Select, $argument->getType()); + } } diff --git a/test/unit/Sql/CombineTest.php b/test/unit/Sql/CombineTest.php index 476edf22..bbe77ff7 100644 --- a/test/unit/Sql/CombineTest.php +++ b/test/unit/Sql/CombineTest.php @@ -193,6 +193,53 @@ public function testGetRawState(): void ); } + public function testCombineWithArrayOfSelectAndModifier(): void + { + $this->combine->combine([ + [new Select('t1'), 'UNION', 'ALL'], + [new Select('t2'), 'INTERSECT'], + ]); + + self::assertEquals( + '(SELECT "t1".* FROM "t1") INTERSECT (SELECT "t2".* FROM "t2")', + $this->combine->getSqlString() + ); + } + + public function testAlignColumnsAppendsNullExpressionsForMissingColumns(): void + { + $select1 = new Select('t1'); + $select1->columns(['a' => 'a']); + + $select2 = new Select('t2'); + $select2->columns(['a' => 'a', 'b' => 'b']); + + $this->combine->union([$select1, $select2])->alignColumns(); + + $columns1 = $select1->getRawState('columns'); + self::assertArrayHasKey('b', $columns1); + self::assertInstanceOf(Expression::class, $columns1['b']); + } + + public function testConstructorWithSelectDelegatesToCombine(): void + { + $select = new Select('foo'); + $combine = new Combine($select, Combine::COMBINE_EXCEPT, 'ALL'); + + $rawState = $combine->getRawState(); + self::assertCount(1, $rawState['combine']); + self::assertSame('except', $rawState['combine'][0]['type']); + self::assertSame('ALL', $rawState['combine'][0]['modifier']); + } + + public function testAlignColumnsReturnsEarlyWhenEmpty(): void + { + $combine = new Combine(); + $result = $combine->alignColumns(); + + self::assertSame($combine, $result); + } + protected function getMockAdapter(): Adapter|MockObject { $parameterContainer = new ParameterContainer(); diff --git a/test/unit/Sql/Ddl/Column/ColumnTest.php b/test/unit/Sql/Ddl/Column/ColumnTest.php index d32556e6..d17c3119 100644 --- a/test/unit/Sql/Ddl/Column/ColumnTest.php +++ b/test/unit/Sql/Ddl/Column/ColumnTest.php @@ -8,7 +8,9 @@ use PhpDb\Sql\Argument\Literal; use PhpDb\Sql\Argument\Value; use PhpDb\Sql\Ddl\Column\Column; +use PhpDb\Sql\Ddl\Constraint\PrimaryKey; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; #[CoversMethod(Column::class, '__construct')] @@ -23,6 +25,7 @@ #[CoversMethod(Column::class, 'getOptions')] #[CoversMethod(Column::class, 'addConstraint')] #[CoversMethod(Column::class, 'getExpressionData')] +#[Group('unit')] final class ColumnTest extends TestCase { public function testConstructor(): void @@ -278,4 +281,33 @@ public function testGetExpressionDataWithBoolDefault(): void Argument::value(false), ], $expressionData['values']); } + + public function testAddConstraintAppendsConstraintToColumn(): void + { + $column = new Column('id'); + + $result = $column->addConstraint(new PrimaryKey()); + + self::assertSame($column, $result); + } + + public function testGetExpressionDataIncludesConstraints(): void + { + $column = new Column('id'); + $column->addConstraint(new PrimaryKey()); + + $expressionData = $column->getExpressionData(); + + self::assertStringContainsString('PRIMARY KEY', $expressionData['spec']); + } + + public function testGetExpressionDataIncludesConstraintValues(): void + { + $column = new Column('id'); + $column->addConstraint(new PrimaryKey('id', 'pk_id')); + + $expressionData = $column->getExpressionData(); + + self::assertNotEmpty($expressionData['values']); + } } diff --git a/test/unit/Sql/Ddl/Column/IntegerTest.php b/test/unit/Sql/Ddl/Column/IntegerTest.php index d86c6653..b8f69a77 100644 --- a/test/unit/Sql/Ddl/Column/IntegerTest.php +++ b/test/unit/Sql/Ddl/Column/IntegerTest.php @@ -9,10 +9,13 @@ use PhpDb\Sql\Ddl\Column\Integer; use PhpDb\Sql\Ddl\Constraint\PrimaryKey; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; #[CoversMethod(Integer::class, '__construct')] +#[CoversMethod(Integer::class, 'getExpressionData')] #[CoversMethod(Column::class, 'getExpressionData')] +#[Group('unit')] final class IntegerTest extends TestCase { public function testObjectConstruction(): void @@ -44,4 +47,23 @@ public function testGetExpressionData(): void Argument::literal('INTEGER'), ], $expressionData['values']); } + + public function testGetExpressionDataIncludesLengthWhenOptionSet(): void + { + $column = new Integer('id'); + $column->setOption('length', '11'); + + $expressionData = $column->getExpressionData(); + + self::assertStringContainsString('(11)', $expressionData['spec']); + } + + public function testGetExpressionDataExcludesLengthWhenNotSet(): void + { + $column = new Integer('id'); + + $expressionData = $column->getExpressionData(); + + self::assertStringNotContainsString('(', $expressionData['spec']); + } } diff --git a/test/unit/Sql/ExpressionTest.php b/test/unit/Sql/ExpressionTest.php index d01f7016..2ee05788 100644 --- a/test/unit/Sql/ExpressionTest.php +++ b/test/unit/Sql/ExpressionTest.php @@ -197,7 +197,6 @@ public function testGetExpressionDataThrowsExceptionWhenParameterCountMismatch() public function testConstructorWithMultipleArguments(): void { - // Test deprecated multi-argument constructor $expression = new Expression('? + ? - ?', 1, 2, 3); $expressionData = $expression->getExpressionData(); @@ -209,4 +208,35 @@ public function testConstructorWithMultipleArguments(): void Argument::value(3), ], $expressionData['values']); } + + public function testSetExpressionThrowsOnEmptyString(): void + { + $expression = new Expression(); + + $this->expectException(InvalidArgumentException::class); + $expression->setExpression(''); + } + + public function testSetParametersWrapsArrayInValuesArgument(): void + { + $expression = new Expression('? IN (?)', [Argument::identifier('id'), [1, 2, 3]]); + + $data = $expression->getExpressionData(); + + self::assertCount(2, $data['values']); + self::assertInstanceOf(Argument\Values::class, $data['values'][1]); + } + + public function testGetExpressionDataUsesRegexWhenPlaceholderCountMismatches(): void + { + $expression = new Expression('uf.user_id = :user_id OR uf.friend_id = :user_id', ['user_id' => 1]); + + $expressionData = $expression->getExpressionData(); + + self::assertEquals( + 'uf.user_id = :user_id OR uf.friend_id = :user_id', + $expressionData['spec'] + ); + self::assertCount(1, $expressionData['values']); + } } diff --git a/test/unit/Sql/InsertTest.php b/test/unit/Sql/InsertTest.php index b1865ae8..cbc4d745 100644 --- a/test/unit/Sql/InsertTest.php +++ b/test/unit/Sql/InsertTest.php @@ -6,6 +6,7 @@ use Override; use PhpDb\Adapter\Driver\DriverInterface; +use PhpDb\Adapter\Driver\PdoDriverInterface; use PhpDb\Adapter\Driver\StatementInterface; use PhpDb\Adapter\ParameterContainer; use PhpDb\Adapter\StatementContainer; @@ -440,4 +441,25 @@ public function testPrepareStatementCreatesParameterContainerWhenNotPresent(): v self::assertSame($mockStatement, $result); } + + public function testProcessInsertWithPdoDriverUsesDriverColumnQuoting(): void + { + $mockDriver = $this->getMockBuilder(PdoDriverInterface::class)->getMock(); + $mockDriver->expects($this->any())->method('getPrepareType')->willReturn('positional'); + $mockDriver->expects($this->any()) + ->method('formatParameterName') + ->willReturnCallback(fn(string $name): string => ':' . $name); + $mockAdapter = $this->createMockAdapter($mockDriver); + + $mockStatement = new StatementContainer(); + + $this->insert->into('foo') + ->values(['bar' => 'baz', 'boo' => 'qux']); + + $this->insert->prepareStatement($mockAdapter, $mockStatement); + + $sql = $mockStatement->getSql(); + self::assertStringContainsString(':c_0', $sql); + self::assertStringContainsString(':c_1', $sql); + } } diff --git a/test/unit/Sql/JoinTest.php b/test/unit/Sql/JoinTest.php index 9bd2b331..4faaa59b 100644 --- a/test/unit/Sql/JoinTest.php +++ b/test/unit/Sql/JoinTest.php @@ -4,6 +4,7 @@ namespace PhpDbTest\Sql; +use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\Join; use PhpDb\Sql\Select; use PhpDbTest\DeprecatedAssertionsTrait; @@ -17,7 +18,6 @@ #[IgnoreDeprecations] #[RequiresPhp('<= 8.6')] -#[CoversMethod(Join::class, '__construct')] #[CoversMethod(Join::class, 'rewind')] #[CoversMethod(Join::class, 'current')] #[CoversMethod(Join::class, 'key')] @@ -154,4 +154,13 @@ public function testReset(): void self::assertEquals(0, $join->count()); } + + public function testJoinThrowsOnInvalidMultiElementArray(): void + { + $join = new Join(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("expects 'b' as a single element associative array"); + $join->join(['a' => 'b', 'c' => 'd'], 'on'); + } } diff --git a/test/unit/Sql/Platform/AbstractPlatformTest.php b/test/unit/Sql/Platform/AbstractPlatformTest.php new file mode 100644 index 00000000..fe5429e8 --- /dev/null +++ b/test/unit/Sql/Platform/AbstractPlatformTest.php @@ -0,0 +1,137 @@ +platform = new AbstractPlatform(); + } + + public function testSetSubjectReturnsStatic(): void + { + $subject = $this->createMock(SqlInterface::class); + $result = $this->platform->setSubject($subject); + + self::assertSame($this->platform, $result); + } + + public function testSetAndGetTypeDecorator(): void + { + $decorator = $this->createMock(PlatformDecoratorInterface::class); + $this->platform->setTypeDecorator(Select::class, $decorator); + + $decorators = $this->platform->getDecorators(); + + self::assertArrayHasKey(Select::class, $decorators); + self::assertSame($decorator, $decorators[Select::class]); + } + + public function testGetTypeDecoratorReturnsSubjectWhenNoMatch(): void + { + $subject = $this->createMock(SqlInterface::class); + + $result = $this->platform->getTypeDecorator($subject); + + self::assertSame($subject, $result); + } + + public function testGetTypeDecoratorLoopMatchesByInstanceof(): void + { + $decorator = $this->createMock(PlatformDecoratorInterface::class); + $decorator->method('setSubject')->willReturnSelf(); + + $this->platform->setTypeDecorator(Select::class, $decorator); + + $subject = new Select('foo'); + $result = $this->platform->getTypeDecorator($subject); + + self::assertSame($decorator, $result); + } + + public function testPrepareStatementThrowsWhenSubjectNotPreparable(): void + { + $subject = $this->createMock(SqlInterface::class); + $this->platform->setSubject($subject); + + $adapter = $this->createMock(AdapterInterface::class); + $statement = new StatementContainer(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The subject does not appear to implement'); + $this->platform->prepareStatement($adapter, $statement); + } + + public function testGetSqlStringThrowsWhenSubjectNotSqlInterface(): void + { + $subject = $this->createMock(PreparableSqlInterface::class); + $this->platform->setSubject($subject); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The subject does not appear to implement'); + $this->platform->getSqlString(); + } + + public function testGetSqlStringDelegatesToDecoratorSubject(): void + { + $select = new Select('foo'); + $this->platform->setSubject($select); + + $sql = $this->platform->getSqlString(); + + self::assertStringContainsString('SELECT', $sql); + self::assertStringContainsString('"foo"', $sql); + } + + public function testPrepareStatementDelegatesToDecoratorSubject(): void + { + $select = new Select('foo'); + $select->where(['id' => 1]); + $this->platform->setSubject($select); + + $mockPlatform = $this->createMock(PlatformInterface::class); + $mockPlatform->method('quoteIdentifier')->willReturnCallback(fn($v) => '"' . $v . '"'); + $mockPlatform->method('quoteIdentifierInFragment')->willReturnCallback(fn($v) => '"' . $v . '"'); + $mockPlatform->method('getIdentifierSeparator')->willReturn('.'); + $mockPlatform->method('getSqlPlatformDecorator')->willReturn($this->platform); + + $mockDriver = $this->createMock(DriverInterface::class); + $mockDriver->method('formatParameterName')->willReturn('?'); + + $adapter = $this->getMockBuilder(AdapterInterface::class)->getMock(); + $adapter->method('getPlatform')->willReturn($mockPlatform); + $adapter->method('getDriver')->willReturn($mockDriver); + + $statement = new StatementContainer(); + $result = $this->platform->prepareStatement($adapter, $statement); + + self::assertSame($statement, $result); + self::assertStringContainsString('SELECT', $statement->getSql()); + } +} diff --git a/test/unit/Sql/Platform/PlatformTest.php b/test/unit/Sql/Platform/PlatformTest.php index eb3be938..027f6ddf 100644 --- a/test/unit/Sql/Platform/PlatformTest.php +++ b/test/unit/Sql/Platform/PlatformTest.php @@ -5,12 +5,19 @@ namespace PhpDbTest\Sql\Platform; use PhpDb\Adapter\Adapter; +use PhpDb\Adapter\AdapterInterface; use PhpDb\Adapter\Driver\DriverInterface; +use PhpDb\Adapter\Platform\PlatformInterface; use PhpDb\Adapter\StatementContainer; use PhpDb\ResultSet\ResultSet; +use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\Platform\Platform; +use PhpDb\Sql\Platform\PlatformDecoratorInterface; +use PhpDb\Sql\PreparableSqlInterface; +use PhpDb\Sql\Select; +use PhpDb\Sql\SqlInterface; use PhpDbTest\TestAsset; -use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\MockObject\MockObject; @@ -20,6 +27,15 @@ #[IgnoreDeprecations] #[RequiresPhp('<= 8.6')] +#[CoversMethod(Platform::class, '__construct')] +#[CoversMethod(Platform::class, 'setTypeDecorator')] +#[CoversMethod(Platform::class, 'getTypeDecorator')] +#[CoversMethod(Platform::class, 'getDecorators')] +#[CoversMethod(Platform::class, 'prepareStatement')] +#[CoversMethod(Platform::class, 'getSqlString')] +#[CoversMethod(Platform::class, 'resolvePlatformName')] +#[CoversMethod(Platform::class, 'resolvePlatform')] +#[CoversMethod(Platform::class, 'getDefaultPlatform')] class PlatformTest extends TestCase { /** @@ -53,22 +69,6 @@ public function testResolvePlatformName(): void self::assertEquals('sql92', $reflectionMethod->invoke($platform, new TestAsset\TrustingSql92Platform())); } - #[Group('6890')] - public function testAbstractPlatformCrashesGracefullyOnMissingDefaultPlatform(): void - { - $this->markTestSkipped( - 'Cannot modify readonly properties in Adapter - test is incompatible with readonly properties' - ); - } - - #[Group('6890')] - public function testAbstractPlatformCrashesGracefullyOnMissingDefaultPlatformWithGetDecorators(): void - { - $this->markTestSkipped( - 'Cannot modify readonly properties in Adapter - test is incompatible with readonly properties' - ); - } - protected function resolveAdapter(string $platformName): Adapter { $platform = null; @@ -100,4 +100,170 @@ protected function resolveAdapter(string $platformName): Adapter return new Adapter($mockDriver, $platform, new ResultSet()); } + + public function testSetTypeDecoratorRegistersDecorator(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $decorator = $this->createMock(PlatformDecoratorInterface::class); + $platform->setTypeDecorator(Select::class, $decorator); + + $decorators = $platform->getDecorators(); + self::assertArrayHasKey(Select::class, $decorators); + self::assertSame($decorator, $decorators[Select::class]); + } + + public function testGetTypeDecoratorReturnsSubjectWhenNoDecoratorRegistered(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $select = new Select('foo'); + $result = $platform->getTypeDecorator($select); + + self::assertSame($select, $result); + } + + public function testGetDefaultPlatformReturnsInstance(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $reflectionMethod = new ReflectionMethod($platform, 'getDefaultPlatform'); + $result = $reflectionMethod->invoke($platform); + + self::assertSame($adapterPlatform, $result); + } + + /** + * @throws ReflectionException + */ + public function testResolvePlatformNameCachesResult(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $reflectionMethod = new ReflectionMethod($platform, 'resolvePlatformName'); + + $first = $reflectionMethod->invoke($platform, null); + $second = $reflectionMethod->invoke($platform, null); + + self::assertEquals($first, $second); + self::assertEquals('sql92', $first); + } + + public function testResolvePlatformWithAdapterInterface(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $mockPlatform = $this->createMock(PlatformInterface::class); + $mockPlatform->method('getName')->willReturn('TestPlatform'); + + $mockAdapter = $this->createMock(AdapterInterface::class); + $mockAdapter->expects($this->once())->method('getPlatform')->willReturn($mockPlatform); + + $reflectionMethod = new ReflectionMethod($platform, 'resolvePlatform'); + $result = $reflectionMethod->invoke($platform, $mockAdapter); + + self::assertSame($mockPlatform, $result); + } + + public function testResolvePlatformWithPlatformInterface(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $mockPlatform = $this->createMock(PlatformInterface::class); + + $reflectionMethod = new ReflectionMethod($platform, 'resolvePlatform'); + $result = $reflectionMethod->invoke($platform, $mockPlatform); + + self::assertSame($mockPlatform, $result); + } + + public function testPrepareStatementThrowsWhenSubjectNotPreparable(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $subject = $this->createMock(SqlInterface::class); + $platform->setSubject($subject); + + $adapter = $this->resolveAdapter('sql92'); + $statement = new StatementContainer(); + + $this->expectException(RuntimeException::class); + $platform->prepareStatement($adapter, $statement); + } + + public function testGetSqlStringThrowsWhenSubjectNotSqlInterface(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $subject = $this->createMock(PreparableSqlInterface::class); + $platform->setSubject($subject); + + $this->expectException(RuntimeException::class); + $platform->getSqlString($adapterPlatform); + } + + public function testGetSqlStringDelegatesToTypeDecorator(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $select = new Select('foo'); + $platform->setSubject($select); + + $sql = $platform->getSqlString($adapterPlatform); + + self::assertStringContainsString('SELECT', $sql); + self::assertStringContainsString('"foo"', $sql); + } + + public function testGetTypeDecoratorMatchesExactClass(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $decorator = $this->createMock(PlatformDecoratorInterface::class); + $decorator->expects(self::once())->method('setSubject'); + $platform->setTypeDecorator(Select::class, $decorator); + + $select = new Select('foo'); + $result = $platform->getTypeDecorator($select); + + self::assertSame($decorator, $result); + } + + public function testGetTypeDecoratorFallsThroughWhenNoMatch(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $decorator = $this->createMock(PlatformDecoratorInterface::class); + $platform->setTypeDecorator(\PhpDb\Sql\Insert::class, $decorator); + + $select = new Select('foo'); + $result = $platform->getTypeDecorator($select); + + self::assertSame($select, $result); + } + + public function testGetTypeDecoratorMatchesByInstanceofLoop(): void + { + $adapterPlatform = new TestAsset\TrustingSql92Platform(); + $platform = new Platform($adapterPlatform); + + $innerPlatform = new \PhpDb\Sql\Platform\AbstractPlatform(); + $platform->setTypeDecorator(SqlInterface::class, $innerPlatform); + + $select = new Select('foo'); + $result = $platform->getTypeDecorator($select); + + self::assertSame($innerPlatform, $result); + } } diff --git a/test/unit/Sql/Predicate/InTest.php b/test/unit/Sql/Predicate/InTest.php index ec4b8212..691eacdc 100644 --- a/test/unit/Sql/Predicate/InTest.php +++ b/test/unit/Sql/Predicate/InTest.php @@ -5,12 +5,15 @@ namespace PhpDbTest\Sql\Predicate; use PhpDb\Sql\Argument; +use PhpDb\Sql\Argument\Select as ArgumentSelect; +use PhpDb\Sql\Argument\Values; use PhpDb\Sql\ArgumentInterface; use PhpDb\Sql\ArgumentType; use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\Predicate\In; use PhpDb\Sql\Select; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; #[CoversMethod(In::class, '__construct')] @@ -19,6 +22,7 @@ #[CoversMethod(In::class, 'setValueSet')] #[CoversMethod(In::class, 'getValueSet')] #[CoversMethod(In::class, 'getExpressionData')] +#[Group('unit')] final class InTest extends TestCase { public function testEmptyConstructorYieldsNullIdentifierAndValueSet(): void @@ -275,4 +279,26 @@ public function testGetExpressionDataThrowsExceptionWhenValueSetNotSet(): void $this->expectExceptionMessage('Value set must be provided for IN predicate'); $in->getExpressionData(); } + + public function testSetValueSetWithSelectWrapsInArgumentSelect(): void + { + $in = new In(); + $select = new Select('users'); + + $in->setValueSet($select); + + $valueSet = $in->getValueSet(); + self::assertInstanceOf(ArgumentSelect::class, $valueSet); + self::assertSame(ArgumentType::Select, $valueSet->getType()); + } + + public function testSetValueSetWithArgumentInterfacePassesThrough(): void + { + $in = new In(); + $values = new Values([1, 2, 3]); + + $in->setValueSet($values); + + self::assertSame($values, $in->getValueSet()); + } } diff --git a/test/unit/Sql/Predicate/IsNullTest.php b/test/unit/Sql/Predicate/IsNullTest.php index 46343d35..4659bf48 100644 --- a/test/unit/Sql/Predicate/IsNullTest.php +++ b/test/unit/Sql/Predicate/IsNullTest.php @@ -4,12 +4,14 @@ namespace PhpDbTest\Sql\Predicate; +use PhpDb\Sql\Argument\Identifier; use PhpDb\Sql\ArgumentInterface; use PhpDb\Sql\ArgumentType; use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\Predicate\IsNotNull; use PhpDb\Sql\Predicate\IsNull; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; #[CoversMethod(IsNull::class, '__construct')] @@ -18,6 +20,7 @@ #[CoversMethod(IsNull::class, 'setSpecification')] #[CoversMethod(IsNull::class, 'getSpecification')] #[CoversMethod(IsNull::class, 'getExpressionData')] +#[Group('unit')] final class IsNullTest extends TestCase { public function testEmptyConstructorYieldsNullIdentifier(): void @@ -104,4 +107,25 @@ public function testGetExpressionDataThrowsExceptionWhenIdentifierNotSet(): void $this->expectExceptionMessage('Identifier must be specified'); $isNull->getExpressionData(); } + + public function testSetIdentifierWithStringConvertsToIdentifier(): void + { + $isNull = new IsNull(); + + $isNull->setIdentifier('foo'); + + $identifier = $isNull->getIdentifier(); + self::assertInstanceOf(Identifier::class, $identifier); + self::assertSame('foo', $identifier->getValue()); + } + + public function testSetIdentifierWithArgumentInterfacePassesThrough(): void + { + $isNull = new IsNull(); + $identifier = new Identifier('bar'); + + $isNull->setIdentifier($identifier); + + self::assertSame($identifier, $isNull->getIdentifier()); + } } diff --git a/test/unit/Sql/Predicate/OperatorTest.php b/test/unit/Sql/Predicate/OperatorTest.php index 7f4b647e..6afeaf4d 100644 --- a/test/unit/Sql/Predicate/OperatorTest.php +++ b/test/unit/Sql/Predicate/OperatorTest.php @@ -5,12 +5,15 @@ namespace PhpDbTest\Sql\Predicate; use PhpDb\Sql\Argument\Identifier; +use PhpDb\Sql\Argument\Select; use PhpDb\Sql\Argument\Value; use PhpDb\Sql\ArgumentInterface; use PhpDb\Sql\ArgumentType; use PhpDb\Sql\Exception\InvalidArgumentException; +use PhpDb\Sql\Expression; use PhpDb\Sql\Predicate\Operator; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; #[CoversMethod(Operator::class, '__construct')] @@ -21,6 +24,7 @@ #[CoversMethod(Operator::class, 'getRight')] #[CoversMethod(Operator::class, 'setRight')] #[CoversMethod(Operator::class, 'getExpressionData')] +#[Group('unit')] final class OperatorTest extends TestCase { public function testEmptyConstructorYieldsNullLeftAndRightValues(): void @@ -187,4 +191,28 @@ public function testGetExpressionDataThrowsExceptionWhenRightNotSet(): void $this->expectExceptionMessage('Right expression must be specified'); $operator->getExpressionData(); } + + public function testSetLeftWithExpressionInterfaceWrapsInSelect(): void + { + $operator = new Operator(); + $expression = new Expression('NOW()'); + + $operator->setLeft($expression); + + $left = $operator->getLeft(); + self::assertInstanceOf(Select::class, $left); + self::assertSame(ArgumentType::Select, $left->getType()); + } + + public function testSetRightWithExpressionInterfaceWrapsInSelect(): void + { + $operator = new Operator(); + $expression = new Expression('NOW()'); + + $operator->setRight($expression); + + $right = $operator->getRight(); + self::assertInstanceOf(Select::class, $right); + self::assertSame(ArgumentType::Select, $right->getType()); + } } diff --git a/test/unit/Sql/Predicate/PredicateSetTest.php b/test/unit/Sql/Predicate/PredicateSetTest.php index 1b5f5a26..d5e36985 100644 --- a/test/unit/Sql/Predicate/PredicateSetTest.php +++ b/test/unit/Sql/Predicate/PredicateSetTest.php @@ -5,6 +5,7 @@ namespace PhpDbTest\Sql\Predicate; use PhpDb\Sql\Argument; +use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\Expression as SqlExpression; use PhpDb\Sql\Predicate\Expression; use PhpDb\Sql\Predicate\In; @@ -12,6 +13,7 @@ use PhpDb\Sql\Predicate\IsNull; use PhpDb\Sql\Predicate\Literal; use PhpDb\Sql\Predicate\Operator; +use PhpDb\Sql\Predicate\PredicateInterface; use PhpDb\Sql\Predicate\PredicateSet; use PhpDbTest\DeprecatedAssertionsTrait; use PHPUnit\Framework\Attributes\CoversMethod; @@ -211,4 +213,33 @@ public function testAddPredicatesWithMultipleExpressions(): void self::assertInstanceOf(Expression::class, $predicates[0][1]); self::assertInstanceOf(Expression::class, $predicates[1][1]); } + + public function testAddPredicateThrowsOnInvalidCombination(): void + { + $predicateSet = new PredicateSet(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid combination: expected 'AND' or 'OR'"); + $predicateSet->addPredicate(new IsNull('foo'), 'XOR'); + } + + public function testAddPredicatesThrowsWhenStringKeyUsedWithPredicateInterface(): void + { + $predicateSet = new PredicateSet(); + $mock = $this->createMock(PredicateInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Using Predicate must not use string keys'); + $predicateSet->addPredicates(['key' => $mock]); + } + + public function testGetExpressionDataReturnsEmptyWhenNoPredicates(): void + { + $predicateSet = new PredicateSet(); + + $expressionData = $predicateSet->getExpressionData(); + + self::assertSame('', $expressionData['spec']); + self::assertSame([], $expressionData['values']); + } } diff --git a/test/unit/Sql/Predicate/PredicateTest.php b/test/unit/Sql/Predicate/PredicateTest.php index 1912d191..91eabbc9 100644 --- a/test/unit/Sql/Predicate/PredicateTest.php +++ b/test/unit/Sql/Predicate/PredicateTest.php @@ -8,12 +8,16 @@ use PhpDb\Adapter\Exception\VunerablePlatformQuoteException; use PhpDb\Adapter\Platform\Sql92; use PhpDb\Sql\Argument; +use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\Expression; use PhpDb\Sql\Predicate\Predicate; +use PhpDb\Sql\Predicate\PredicateInterface; use PhpDb\Sql\Select; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +#[Group('unit')] final class PredicateTest extends TestCase { public function testEqualToCreatesOperatorPredicate(): void @@ -373,28 +377,49 @@ public function testLiteral(): void self::assertEquals([$expression], $expressionData['values']); } - /** - * removed throws ErrorException to fix phpstan issue - */ - public function testCanCreateExpressionsWithoutAnyBoundSqlParameters(): void + public function testUnnestThrowsWhenNotNested(): void { - $this->markTestSkipped( - 'This test is skipped because it triggers exception in quoteValue(). That can not be expected currently.' - ); + $predicate = new Predicate(); - /** @phpstan-ignore deadCode.unreachable */ - $where1 = new Predicate(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Not nested'); + $predicate->unnest(); + } - $where1->expression('some_expression()'); + public function testPredicateMethodAddsCustomPredicateInterface(): void + { + $predicate = new Predicate(); + $mock = $this->createMock(PredicateInterface::class); - $actual = $this->makeSqlString($where1); + $result = $predicate->predicate($mock); - self::assertSame( - 'SELECT "a_table".* FROM "a_table" WHERE (some_expression())', - $actual - ); + self::assertSame($predicate, $result); + self::assertCount(1, $predicate); + } + + public function testMagicGetNestReturnsNestedPredicate(): void + { + $predicate = new Predicate(); + + $nested = $predicate->nest; + + self::assertInstanceOf(Predicate::class, $nested); + self::assertNotSame($predicate, $nested); } + public function testMagicGetUnnestReturnsParentPredicate(): void + { + $predicate = new Predicate(); + + $nested = $predicate->nest; + $parent = $nested->unnest; + + self::assertSame($predicate, $parent); + } + + /** + * removed throws ErrorException to fix phpstan issue + */ /** * @throws ErrorException */ diff --git a/test/unit/Sql/SelectTest.php b/test/unit/Sql/SelectTest.php index 0f8ea959..1dcd508b 100644 --- a/test/unit/Sql/SelectTest.php +++ b/test/unit/Sql/SelectTest.php @@ -68,6 +68,9 @@ #[CoversMethod(Select::class, 'processLimit')] #[CoversMethod(Select::class, 'processOffset')] #[CoversMethod(Select::class, 'processCombine')] +#[CoversMethod(Select::class, 'processStatementStart')] +#[CoversMethod(Select::class, 'processStatementEnd')] +#[CoversMethod(Select::class, 'resolveTable')] final class SelectTest extends TestCase { use AdapterTestTrait; @@ -208,29 +211,6 @@ public function testBadJoin(): void $select->join(['foo'], 'x = y'); } - /** - * @throws ReflectionException - */ - #[TestDox('unit test: Test processJoins() exception with bad join name')] - public function testBadJoinName(): void - { - $mockExpression = $this->getMockBuilder(ExpressionInterface::class) - ->getMock(); - $mockDriver = $this->getMockBuilder(DriverInterface::class)->getMock(); - $mockDriver->expects($this->any())->method('formatParameterName')->willReturn('?'); - $parameterContainer = new ParameterContainer(); - - $select = new Select(); - $select->join(['foo' => $mockExpression], 'x = y'); - - $sr = new ReflectionObject($select); - - $mr = $sr->getMethod('processJoins'); - - $this->expectException(InvalidArgumentException::class); - $mr->invokeArgs($select, [new Sql92(), $mockDriver, $parameterContainer]); - } - #[TestDox('unit test: Test where() returns Select object (is chainable)')] public function testWhereReturnsSameSelectObject(): void { @@ -1574,4 +1554,177 @@ public function testGetThrowsExceptionForInvalidProperty(): void /** @noinspection ALL */ $value = $select->invalidProperty; /** @phpstan-ignore-line */ } + + public function testCombineWrapsStatementInParentheses(): void + { + $select1 = new Select(); + $select1->from('t1'); + + $select2 = new Select(); + $select2->from('t2'); + + $select1->combine($select2); + + $sql = $select1->getSqlString(new TrustingSql92Platform()); + + self::assertStringContainsString('( SELECT', $sql); + self::assertStringContainsString('UNION', $sql); + self::assertStringContainsString(') UNION (', $sql); + } + + public function testOrderWithStringContainingDirection(): void + { + $select = new Select(); + $select->from('foo')->order('name DESC'); + + $sql = $select->getSqlString(new TrustingSql92Platform()); + + self::assertStringContainsString('ORDER BY "name" DESC', $sql); + } + + public function testOrderWithExpressionObject(): void + { + $select = new Select(); + $select->from('foo')->order(new Expression('RAND()')); + + $sql = $select->getSqlString(new TrustingSql92Platform()); + + self::assertStringContainsString('ORDER BY RAND()', $sql); + } + + public function testResetThrowsOnInvalidPart(): void + { + $select = new Select(); + $result = $select->reset('invalid'); + + self::assertSame($select, $result); + } + + public function testColumnsWithPrefixDisabled(): void + { + $select = new Select(); + $select->from('foo')->columns(['col'], false); + + $sql = $select->getSqlString(new TrustingSql92Platform()); + + self::assertStringNotContainsString('"foo"."col"', $sql); + self::assertStringContainsString('"col"', $sql); + } + + public function testGetRawStateInitializesLazyProperties(): void + { + $select = new Select(); + $rawState = $select->getRawState(); + + self::assertInstanceOf(Where::class, $rawState[Select::WHERE]); + self::assertInstanceOf(Join::class, $rawState[Select::JOINS]); + self::assertInstanceOf(Having::class, $rawState[Select::HAVING]); + } + + public function testCombineThrowsWhenAlreadyCombined(): void + { + $select = new Select(); + $select->from('t1'); + $select->combine(new Select('t2')); + + $this->expectException(InvalidArgumentException::class); + $select->combine(new Select('t3')); + } + + public function testResetTableThrowsWhenTableReadOnly(): void + { + $select = new Select('foo'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('read only'); + $select->reset(Select::TABLE); + } + + public function testResetQuantifier(): void + { + $select = new Select(); + $select->from('foo')->quantifier(Select::QUANTIFIER_DISTINCT); + $select->reset(Select::QUANTIFIER); + + self::assertNull($select->getRawState(Select::QUANTIFIER)); + } + + public function testResetCombine(): void + { + $select = new Select(); + $select->from('t1'); + $select->combine(new Select('t2')); + $select->reset(Select::COMBINE); + + self::assertEmpty($select->getRawState(Select::COMBINE)); + } + + public function testSetSpecificationStoresValidSpecification(): void + { + $select = new Select(); + $select->setSpecification('Select', 'CUSTOM %1$s FROM %2$s'); + + $rawState = (new ReflectionObject($select))->getProperty('specifications'); + self::assertSame('CUSTOM %1$s FROM %2$s', $rawState->getValue($select)['Select']); + } + + public function testMagicGetJoinsReturnsJoinInstance(): void + { + $select = new Select(); + + self::assertInstanceOf(Join::class, $select->joins); + } + + public function testCloneDeepCopiesAllSubObjects(): void + { + $select = new Select(); + $select->from('foo'); + $select->where('id = 1'); + $select->having('cnt > 0'); + $select->join('bar', 'foo.id = bar.foo_id'); + + $clone = clone $select; + $clone->where('id = 2'); + $clone->having('cnt > 1'); + $clone->join('baz', 'foo.id = baz.foo_id'); + + self::assertCount(1, $select->where); + self::assertCount(2, $clone->where); + self::assertCount(1, $select->having); + self::assertCount(2, $clone->having); + self::assertCount(1, $select->joins); + self::assertCount(2, $clone->joins); + } + + public function testOrderWithAssociativeArray(): void + { + $select = new Select(); + $select->from('foo')->order(['name' => 'DESC']); + + $sql = $select->getSqlString(new TrustingSql92Platform()); + + self::assertStringContainsString('ORDER BY "name" DESC', $sql); + } + + public function testFromWithAliasArrayResolvesTableAndAlias(): void + { + $select = new Select(); + $select->from(['a' => 'users'])->columns(['id']); + + $sql = $select->getSqlString(new TrustingSql92Platform()); + + self::assertStringContainsString('"users" AS "a"', $sql); + self::assertStringContainsString('"a"."id"', $sql); + } + + public function testColumnsWithPrefixDisabledOmitsTablePrefix(): void + { + $select = new Select(); + $select->from(['a' => 'users'])->columns(['id'], false); + + $sql = $select->getSqlString(new TrustingSql92Platform()); + + self::assertStringContainsString('"id"', $sql); + self::assertStringNotContainsString('"a"."id"', $sql); + } } diff --git a/test/unit/Sql/SqlTest.php b/test/unit/Sql/SqlTest.php index c7359a9c..f9ff635a 100644 --- a/test/unit/Sql/SqlTest.php +++ b/test/unit/Sql/SqlTest.php @@ -12,9 +12,12 @@ use PhpDb\Adapter\Driver\StatementInterface; use PhpDb\Sql\Delete; use PhpDb\Sql\Exception\InvalidArgumentException; +use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\Insert; use PhpDb\Sql\Select; +use PhpDb\Sql\Platform\PlatformDecoratorInterface; use PhpDb\Sql\Sql; +use PhpDb\Sql\TableIdentifier; use PhpDb\Sql\Update; use PhpDbTest\TestAsset; use PHPUnit\Framework\Attributes\CoversMethod; @@ -158,4 +161,87 @@ public function testBuildSqlString(): void $sqlString = $this->sql->buildSqlString($select); self::assertEquals('SELECT "foo".* FROM "foo" WHERE "bar" = \'baz\'', $sqlString); } + + public function testSelectThrowsWhenTableConflicts(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'This Sql object is intended to work with only the table "foo" provided at construction time.' + ); + $this->sql->select(new TableIdentifier('bar')); + } + + public function testInsertThrowsWhenTableConflicts(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'This Sql object is intended to work with only the table "foo" provided at construction time.' + ); + $this->sql->insert(new TableIdentifier('bar')); + } + + public function testUpdateThrowsWhenTableConflicts(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'This Sql object is intended to work with only the table "foo" provided at construction time.' + ); + $this->sql->update(new TableIdentifier('bar')); + } + + public function testDeleteThrowsWhenTableConflicts(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'This Sql object is intended to work with only the table "foo" provided at construction time.' + ); + $this->sql->delete(new TableIdentifier('bar')); + } + + public function testGetSqlPlatformReturnsPlatformDecorator(): void + { + self::assertInstanceOf(PlatformDecoratorInterface::class, $this->sql->getSqlPlatform()); + } + + public function testPrepareStatementThrowsWhenPlatformNotPreparable(): void + { + $decorator = $this->createMock(PlatformDecoratorInterface::class); + $platform = $this->createMock(\PhpDb\Adapter\Platform\PlatformInterface::class); + $platform->method('getSqlPlatformDecorator')->willReturn($decorator); + + $adapter = $this->getMockBuilder(Adapter::class) + ->setConstructorArgs([ + $this->createMock(DriverInterface::class), + $platform, + ]) + ->getMock(); + $adapter->method('getPlatform')->willReturn($platform); + + $sql = new Sql($adapter); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not implement PreparableSqlInterface'); + $sql->prepareStatementForSqlObject($this->sql->select()); + } + + public function testBuildSqlStringThrowsWhenPlatformNotSqlInterface(): void + { + $decorator = $this->createMock(PlatformDecoratorInterface::class); + $platform = $this->createMock(\PhpDb\Adapter\Platform\PlatformInterface::class); + $platform->method('getSqlPlatformDecorator')->willReturn($decorator); + + $adapter = $this->getMockBuilder(Adapter::class) + ->setConstructorArgs([ + $this->createMock(DriverInterface::class), + $platform, + ]) + ->getMock(); + $adapter->method('getPlatform')->willReturn($platform); + + $sql = new Sql($adapter); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not implement SqlInterface'); + $sql->buildSqlString($this->sql->select()); + } } diff --git a/test/unit/Sql/UpdateTest.php b/test/unit/Sql/UpdateTest.php index 8c3dcc43..d653fb69 100644 --- a/test/unit/Sql/UpdateTest.php +++ b/test/unit/Sql/UpdateTest.php @@ -6,8 +6,10 @@ use Override; use PhpDb\Adapter\Driver\DriverInterface; +use PhpDb\Adapter\Driver\PdoDriverInterface; use PhpDb\Adapter\Driver\StatementInterface; use PhpDb\Adapter\ParameterContainer; +use PhpDb\Adapter\StatementContainer; use PhpDb\Sql\AbstractPreparableSql; use PhpDb\Sql\Argument\Identifier; use PhpDb\Sql\Argument\Value; @@ -506,4 +508,52 @@ public function testWhereAcceptsExpressionInterface(): void self::assertInstanceOf(Where::class, $where); self::assertEquals(1, $where->count()); } + + public function testProcessSetWithPdoDriverUsesDriverColumnQuoting(): void + { + $mockDriver = $this->getMockBuilder(PdoDriverInterface::class)->getMock(); + $mockDriver->expects($this->any())->method('getPrepareType')->willReturn('positional'); + $mockDriver->expects($this->any()) + ->method('formatParameterName') + ->willReturnCallback(fn(string $name): string => ':' . $name); + $mockAdapter = $this->createMockAdapter($mockDriver); + + $mockStatement = new StatementContainer(); + + $this->update->table('foo') + ->set(['bar' => 'baz', 'boo' => 'qux']); + + $this->update->prepareStatement($mockAdapter, $mockStatement); + + $sql = $mockStatement->getSql(); + self::assertStringContainsString(':c_0', $sql); + self::assertStringContainsString(':c_1', $sql); + } + + public function testCloneDeepCopiesSetWhereAndJoins(): void + { + $this->update->table('foo') + ->set(['bar' => 'baz']) + ->where('x = y') + ->join('other', 'foo.id = other.id'); + + $clone = clone $this->update; + + $clone->set(['bar' => 'changed']); + $clone->where('z = w'); + $clone->join('another', 'foo.id = another.id'); + + self::assertEquals(['bar' => 'baz'], $this->update->getRawState('set')); + self::assertEquals(['bar' => 'changed'], $clone->getRawState('set')); + + self::assertNotSame( + $this->update->getRawState('where'), + $clone->getRawState('where') + ); + + self::assertNotSame( + $this->update->getRawState('joins'), + $clone->getRawState('joins') + ); + } } diff --git a/test/unit/TableGateway/AbstractTableGatewayTest.php b/test/unit/TableGateway/AbstractTableGatewayTest.php index a53bd1ce..ad845d76 100644 --- a/test/unit/TableGateway/AbstractTableGatewayTest.php +++ b/test/unit/TableGateway/AbstractTableGatewayTest.php @@ -21,8 +21,8 @@ use PhpDb\TableGateway\AbstractTableGateway; use PhpDb\TableGateway\Exception\InvalidArgumentException; use PhpDb\TableGateway\Exception\RuntimeException; -use PhpDb\TableGateway\Feature\AbstractFeature; use PhpDb\TableGateway\Feature\FeatureSet; +use PhpDbTest\TableGateway\Feature\TestAsset\TestTableGatewayFeature; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -732,15 +732,8 @@ public function test__getWithFeatureSetMagicGet(): void { // @codingStandardsIgnoreEnd // Create a custom feature that can handle magic get - $feature = new class extends AbstractFeature { - /** - * @return array> - */ - public function getMagicMethodSpecifications(): array - { - return ['get' => ['customProperty']]; - } - }; + $feature = new TestTableGatewayFeature(); + $feature->magicMethodSpecs = ['get' => ['customProperty']]; // Create a FeatureSet mock that returns true for canCallMagicGet $featureSet = $this->getMockBuilder(FeatureSet::class) diff --git a/test/unit/TableGateway/Feature/EventFeatureTest.php b/test/unit/TableGateway/Feature/EventFeatureTest.php index 4766ad5f..da86cf08 100644 --- a/test/unit/TableGateway/Feature/EventFeatureTest.php +++ b/test/unit/TableGateway/Feature/EventFeatureTest.php @@ -17,6 +17,7 @@ use PhpDb\TableGateway\Feature\EventFeature; use PhpDb\TableGateway\Feature\EventFeatureEventsInterface; use PhpDb\TableGateway\TableGateway; +use PhpDbTest\TableGateway\Feature\TestAsset\TestTableGateway; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -300,12 +301,7 @@ public function testConstructorWithDefaults(): void public function testPreInitializeAddsIdentifiersForCustomTableGatewayClass(): void { // Create a custom subclass of TableGateway (using anonymous class) - $customTableGateway = new class extends TableGateway { - public function __construct() - { - // Skip parent constructor - } - }; + $customTableGateway = new TestTableGateway(); $eventManager = new EventManager(); $feature = new EventFeature($eventManager); diff --git a/test/unit/TableGateway/Feature/FeatureSetTest.php b/test/unit/TableGateway/Feature/FeatureSetTest.php index 93432061..95665c70 100644 --- a/test/unit/TableGateway/Feature/FeatureSetTest.php +++ b/test/unit/TableGateway/Feature/FeatureSetTest.php @@ -11,11 +11,11 @@ use PhpDb\Metadata\MetadataInterface; use PhpDb\Metadata\Object\ConstraintObject; use PhpDb\TableGateway\AbstractTableGateway; -use PhpDb\TableGateway\Feature\AbstractFeature; use PhpDb\TableGateway\Feature\FeatureSet; use PhpDb\TableGateway\Feature\MasterSlaveFeature; use PhpDb\TableGateway\Feature\MetadataFeature; use PhpDb\TableGateway\Feature\SequenceFeature; +use PhpDbTest\TableGateway\Feature\TestAsset\TestTableGatewayFeature; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; @@ -131,12 +131,7 @@ public function testCanCallMagicCallReturnsFalseWhenNoFeaturesHaveBeenAdded(): v public function testCallMagicCallSucceedsForValidMethodOfAddedFeature(): void { - $feature = new class extends AbstractFeature { - public function customMethod(array $args): string - { - return 'result: ' . ($args[0] ?? 'default'); - } - }; + $feature = new TestTableGatewayFeature(); $featureSet = new FeatureSet(); $featureSet->addFeature($feature); @@ -255,22 +250,10 @@ public function testCallMagicCallReturnsNullWhenNoFeatureHasMethod(): void public function testApplyHaltsWhenFeatureReturnsHalt(): void { - $feature1 = new class extends AbstractFeature { - public bool $called = false; - public function testMethod(): string - { - $this->called = true; - return FeatureSet::APPLY_HALT; - } - }; - - $feature2 = new class extends AbstractFeature { - public bool $called = false; - public function testMethod(): void - { - $this->called = true; - } - }; + $feature1 = new TestTableGatewayFeature(); + $feature1->returnValue = FeatureSet::APPLY_HALT; + + $feature2 = new TestTableGatewayFeature(); $featureSet = new FeatureSet([$feature1, $feature2]); $featureSet->apply('testMethod', []); @@ -281,21 +264,8 @@ public function testMethod(): void public function testApplyCallsAllFeaturesWhenNoHalt(): void { - $feature1 = new class extends AbstractFeature { - public bool $called = false; - public function testMethod(): void - { - $this->called = true; - } - }; - - $feature2 = new class extends AbstractFeature { - public bool $called = false; - public function testMethod(): void - { - $this->called = true; - } - }; + $feature1 = new TestTableGatewayFeature(); + $feature2 = new TestTableGatewayFeature(); $featureSet = new FeatureSet([$feature1, $feature2]); $featureSet->apply('testMethod', []); @@ -306,17 +276,11 @@ public function testMethod(): void public function testApplyPassesArgumentsToFeatures(): void { - $feature = new class extends AbstractFeature { - public mixed $receivedArg; - public function testMethod(string $arg): void - { - $this->receivedArg = $arg; - } - }; + $feature = new TestTableGatewayFeature(); $featureSet = new FeatureSet([$feature]); $featureSet->apply('testMethod', ['test value']); - self::assertEquals('test value', $feature->receivedArg); + self::assertEquals(['test value'], $feature->receivedArgs); } } diff --git a/test/unit/TableGateway/Feature/MetadataFeatureTest.php b/test/unit/TableGateway/Feature/MetadataFeatureTest.php index 646a3097..66b929b8 100644 --- a/test/unit/TableGateway/Feature/MetadataFeatureTest.php +++ b/test/unit/TableGateway/Feature/MetadataFeatureTest.php @@ -12,7 +12,6 @@ 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\MockObject\Exception; @@ -28,30 +27,6 @@ class MetadataFeatureTest extends TestCase * @throws Exception * @throws \Exception */ - #[Group('integration-test')] - public function testPostInitialize(): void - { - $this->markTestSkipped('This is an integration test and requires a database connection.'); - /** @phpstan-ignore deadCode.unreachable */ - $tableGatewayMock = $this->getMockBuilder(AbstractTableGateway::class) - ->onlyMethods([]) - ->getMock(); - $metadataMock = $this->getMockBuilder(MetadataInterface::class)->getMock(); - $metadataMock->expects($this->any())->method('getColumnNames')->willReturn(['id', 'name']); - - $constraintObject = new ConstraintObject('id_pk', 'table'); - $constraintObject->setColumns(['id']); - $constraintObject->setType('PRIMARY KEY'); - - $metadataMock->expects($this->any())->method('getConstraints')->willReturn([$constraintObject]); - - $feature = new MetadataFeature($metadataMock); - $feature->setTableGateway($tableGatewayMock); - $feature->postInitialize(); - - self::assertEquals(['id', 'name'], $tableGatewayMock->getColumns()); - } - /** * @throws Exception * @throws \Exception diff --git a/test/unit/TableGateway/Feature/TestAsset/TestTableGateway.php b/test/unit/TableGateway/Feature/TestAsset/TestTableGateway.php new file mode 100644 index 00000000..dd69b725 --- /dev/null +++ b/test/unit/TableGateway/Feature/TestAsset/TestTableGateway.php @@ -0,0 +1,14 @@ + */ + public array $receivedArgs = []; + + public ?string $returnValue = null; + + /** @var array> */ + public array $magicMethodSpecs = []; + + public function testMethod(mixed ...$args): mixed + { + $this->called = true; + $this->receivedArgs = $args; + return $this->returnValue; + } + + public function customMethod(array $args): string + { + $this->called = true; + $this->receivedArgs = $args; + return 'result: ' . ($args[0] ?? 'default'); + } + + /** @return array> */ + public function getMagicMethodSpecifications(): array + { + return $this->magicMethodSpecs; + } +} diff --git a/test/unit/TestAsset/TestSql92Platform.php b/test/unit/TestAsset/TestSql92Platform.php new file mode 100644 index 00000000..8b66369d --- /dev/null +++ b/test/unit/TestAsset/TestSql92Platform.php @@ -0,0 +1,17 @@ + Date: Wed, 25 Mar 2026 20:41:34 +1100 Subject: [PATCH 2/5] Remove dead code in Profiler, add static analysis annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profiler::profilerStart(): remove unreachable else-branch. The union type `string|StatementContainerInterface` guarantees only those two types can be passed; the if/elseif handles both exhaustively, making the else+throw dead code. Clean up unused imports (InvalidArgumentException, is_string). - AbstractPdo::checkEnvironment(): mark the throw inside `if (!extension_loaded('PDO'))` with @codeCoverageIgnore. PDO is a hard requirement — this branch cannot execute in any environment where the class is loadable. - AbstractResultSet::initialize(): add @phpstan-ignore for Traversable assignment. After array and IteratorAggregate branches are handled, the remaining Traversable is necessarily an Iterator, but PHPStan cannot narrow it. --- src/Adapter/Driver/Pdo/AbstractPdo.php | 2 ++ src/Adapter/Profiler/Profiler.php | 13 ++----------- src/ResultSet/AbstractResultSet.php | 1 + 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/Adapter/Driver/Pdo/AbstractPdo.php b/src/Adapter/Driver/Pdo/AbstractPdo.php index 1984c786..048bf530 100644 --- a/src/Adapter/Driver/Pdo/AbstractPdo.php +++ b/src/Adapter/Driver/Pdo/AbstractPdo.php @@ -73,9 +73,11 @@ public function getProfiler(): ?ProfilerInterface public function checkEnvironment(): bool { if (! extension_loaded('PDO')) { + // @codeCoverageIgnoreStart throw new Exception\RuntimeException( 'The PDO extension is required for this adapter but the extension is not loaded' ); + // @codeCoverageIgnoreEnd } return true; } diff --git a/src/Adapter/Profiler/Profiler.php b/src/Adapter/Profiler/Profiler.php index dae0b7e4..10a5a72f 100644 --- a/src/Adapter/Profiler/Profiler.php +++ b/src/Adapter/Profiler/Profiler.php @@ -5,11 +5,9 @@ namespace PhpDb\Adapter\Profiler; use PhpDb\Adapter\Exception; -use PhpDb\Adapter\Exception\InvalidArgumentException; use PhpDb\Adapter\StatementContainerInterface; use function end; -use function is_string; use function microtime; class Profiler implements ProfilerInterface @@ -20,10 +18,7 @@ class Profiler implements ProfilerInterface /** @var int */ protected $currentIndex = 0; - /** - * @throws InvalidArgumentException - * @return $this Provides a fluent interface - */ + /** @return $this Provides a fluent interface */ public function profilerStart(string|StatementContainerInterface $target): ProfilerInterface { $profileInformation = [ @@ -39,12 +34,8 @@ public function profilerStart(string|StatementContainerInterface $target): Profi if ($container !== null) { $profileInformation['parameters'] = clone $container; } - } elseif (is_string($target)) { - $profileInformation['sql'] = $target; } else { - throw new Exception\InvalidArgumentException( - __FUNCTION__ . ' takes either a StatementContainer or a string' - ); + $profileInformation['sql'] = $target; } $this->profiles[$this->currentIndex] = $profileInformation; diff --git a/src/ResultSet/AbstractResultSet.php b/src/ResultSet/AbstractResultSet.php index c0b15b31..b909f4c1 100644 --- a/src/ResultSet/AbstractResultSet.php +++ b/src/ResultSet/AbstractResultSet.php @@ -80,6 +80,7 @@ public function initialize(iterable $dataSource): ResultSetInterface /** @phpstan-ignore assign.propertyType */ $this->dataSource = $dataSource->getIterator(); } else { + /** @phpstan-ignore assign.propertyType */ $this->dataSource = $dataSource; } From cf1f62387cd30a03b4b3a6fb398493e694710681 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Thu, 26 Mar 2026 09:45:33 +1100 Subject: [PATCH 3/5] Rename test methods to describe behaviour not implementation Rename ~30 test methods across Adapter test suite to follow the convention of naming tests after what they verify rather than which method they call. Pattern applied: - Fluent/chaining tests: testFluent - Getter tests: testReturns - Throw tests: testThrowsOn - Delegation tests: testDelegatesToX Simple getter/setter tests (testSetSql, testOffsetGet, etc.) left as-is where the method name already describes the behaviour. --- test/unit/Adapter/AdapterTest.php | 18 ++++++++--------- .../Adapter/Driver/Pdo/ConnectionTest.php | 4 ++-- test/unit/Adapter/Driver/Pdo/PdoTest.php | 9 +++++---- test/unit/Adapter/Driver/Pdo/ResultTest.php | 10 +++++----- .../unit/Adapter/Driver/Pdo/StatementTest.php | 20 +++++++------------ test/unit/Adapter/Platform/Sql92Test.php | 6 +++--- test/unit/Adapter/Profiler/ProfilerTest.php | 8 ++++---- 7 files changed, 34 insertions(+), 41 deletions(-) diff --git a/test/unit/Adapter/AdapterTest.php b/test/unit/Adapter/AdapterTest.php index 5640b182..233040d5 100644 --- a/test/unit/Adapter/AdapterTest.php +++ b/test/unit/Adapter/AdapterTest.php @@ -70,14 +70,14 @@ protected function setUp(): void } #[TestDox('unit test: Test setProfiler() will store profiler')] - public function testSetProfiler(): void + public function testFluentSetProfiler(): void { $ret = $this->adapter->setProfiler(new Profiler\Profiler()); self::assertSame($this->adapter, $ret); } #[TestDox('unit test: Test getProfiler() will store profiler')] - public function testGetProfiler(): void + public function testGetProfilerReturnsProfiler(): void { $this->adapter->setProfiler($profiler = new Profiler\Profiler()); self::assertSame($profiler, $this->adapter->getProfiler()); @@ -91,25 +91,25 @@ public function testGetProfiler(): void } #[TestDox('unit test: Test getDriver() will return driver object')] - public function testGetDriver(): void + public function testGetDriverReturnsDriver(): void { self::assertSame($this->mockDriver, $this->adapter->getDriver()); } #[TestDox('unit test: Test getPlatform() returns platform object')] - public function testGetPlatform(): void + public function testGetPlatformReturnsPlatform(): void { self::assertSame($this->mockPlatform, $this->adapter->getPlatform()); } #[TestDox('unit test: Test getPlatform() returns platform object')] - public function testGetQueryResultSetPrototype(): void + public function testGetQueryResultSetPrototypeReturnsResultSet(): void { self::assertInstanceOf(ResultSetInterface::class, $this->adapter->getQueryResultSetPrototype()); } #[TestDox('unit test: Test getCurrentSchema() returns current schema from connection object')] - public function testGetCurrentSchema(): void + public function testGetCurrentSchemaDelegatesToConnection(): void { $this->mockConnection->expects($this->any())->method('getCurrentSchema')->willReturn('FooSchema'); self::assertEquals('FooSchema', $this->adapter->getCurrentSchema()); @@ -218,15 +218,13 @@ public function testQueryWhenExecutedProducesAResultSetObjectWhenResultIsQuery() } #[TestDox('unit test: Test createStatement() produces a statement object')] - public function testCreateStatement(): void + public function testCreateStatementDelegatesToDriver(): void { self::assertSame($this->mockStatement, $this->adapter->createStatement()); } - // @codingStandardsIgnoreStart - public function test__get(): void + public function testMagicGetReturnsDriverAndPlatformCaseInsensitively(): void { - // @codingStandardsIgnoreEnd self::assertSame($this->mockDriver, $this->adapter->driver); /** @phpstan-ignore property.notFound */ self::assertSame($this->mockDriver, $this->adapter->DrivER); diff --git a/test/unit/Adapter/Driver/Pdo/ConnectionTest.php b/test/unit/Adapter/Driver/Pdo/ConnectionTest.php index a527fec1..ea3c57ad 100644 --- a/test/unit/Adapter/Driver/Pdo/ConnectionTest.php +++ b/test/unit/Adapter/Driver/Pdo/ConnectionTest.php @@ -56,7 +56,7 @@ protected function setUp(): void /** * Test getConnectedDsn returns a DSN string if it has been set */ - public function testGetDsn(): void + public function testGetDsnReturnsDsnAfterConnect(): void { $dsn = "sqlite::memory:"; $this->connection->setConnectionParameters(['dsn' => $dsn]); @@ -85,7 +85,7 @@ public function testConstructorWithArraySetsConnectionParameters(): void self::assertSame($params, $connection->getConnectionParameters()); } - public function testSetDriverReturnsInstance(): void + public function testFluentSetDriver(): void { $driver = $this->createMock(PdoDriverInterface::class); diff --git a/test/unit/Adapter/Driver/Pdo/PdoTest.php b/test/unit/Adapter/Driver/Pdo/PdoTest.php index 068f7edd..a200c7f6 100644 --- a/test/unit/Adapter/Driver/Pdo/PdoTest.php +++ b/test/unit/Adapter/Driver/Pdo/PdoTest.php @@ -48,7 +48,7 @@ protected function setUp(): void $this->pdo = new TestPdo([]); } - public function testGetDatabasePlatformName(): void + public function testGetDatabasePlatformNameReturnsCamelAndNaturalFormats(): void { // Test platform name for SqlServer $this->pdo->getConnection()->setConnectionParameters(['pdodriver' => 'sqlsrv']); @@ -75,7 +75,7 @@ public static function getParamsAndType(): array } #[DataProvider('getParamsAndType')] - public function testFormatParameterName(int|string $name, ?string $type, string $expected): void + public function testFormatParameterNameFormatsCorrectly(int|string $name, ?string $type, string $expected): void { $result = $this->pdo->formatParameterName($name, $type); $this->assertEquals($expected, $result); @@ -99,7 +99,7 @@ public function testFormatParameterNameWithInvalidCharacters(string $name): void $this->pdo->formatParameterName($name); } - public function testGetResultPrototype(): void + public function testGetResultPrototypeReturnsResult(): void { $resultPrototype = $this->pdo->getResultPrototype(); @@ -181,7 +181,8 @@ public function testGetProfilerThrowsWhenNotInitialized(): void $pdo = new TestPdo([]); $this->expectException(Error::class); - $pdo->getProfiler(); + + $unused = $pdo->getProfiler(); } public function testGetProfilerReturnsSetProfiler(): void diff --git a/test/unit/Adapter/Driver/Pdo/ResultTest.php b/test/unit/Adapter/Driver/Pdo/ResultTest.php index e81d4ac8..ff2f88b0 100644 --- a/test/unit/Adapter/Driver/Pdo/ResultTest.php +++ b/test/unit/Adapter/Driver/Pdo/ResultTest.php @@ -42,7 +42,7 @@ final class ResultTest extends TestCase /** * Tests current method returns same data on consecutive calls. */ - public function testCurrent(): void + public function testCurrentReturnsSameDataOnConsecutiveCalls(): void { $stub = $this->getMockBuilder('PDOStatement')->getMock(); $stub->expects($this->any()) @@ -55,7 +55,7 @@ public function testCurrent(): void self::assertEquals($result->current(), $result->current()); } - public function testFetchModeException(): void + public function testSetFetchModeThrowsOnInvalidMode(): void { $result = new Result(); @@ -66,7 +66,7 @@ public function testFetchModeException(): void /** * Tests whether the fetch mode was set properly and */ - public function testFetchModeAnonymousObject(): void + public function testFetchModeObjReturnsStdClass(): void { $stub = $this->getMockBuilder('PDOStatement')->getMock(); $stub->expects($this->any()) @@ -84,7 +84,7 @@ public function testFetchModeAnonymousObject(): void /** * Tests whether the fetch mode has a broader range */ - public function testFetchModeRange(): void + public function testFetchModeAcceptsNamedMode(): void { $stub = $this->getMockBuilder('PDOStatement')->getMock(); $stub->expects($this->any()) @@ -97,7 +97,7 @@ public function testFetchModeRange(): void self::assertInstanceOf('stdClass', $result->current()); } - public function testMultipleRewind(): void + public function testRewindResetsIterationToStart(): void { $data = [ ['test' => 1], diff --git a/test/unit/Adapter/Driver/Pdo/StatementTest.php b/test/unit/Adapter/Driver/Pdo/StatementTest.php index 9672aac8..0b6a21b9 100644 --- a/test/unit/Adapter/Driver/Pdo/StatementTest.php +++ b/test/unit/Adapter/Driver/Pdo/StatementTest.php @@ -62,27 +62,24 @@ protected function tearDown(): void { } - public function testSetDriver(): void + public function testFluentSetDriver(): void { self::assertEquals($this->statement, $this->statement->setDriver(new TestPdo([]))); } - public function testSetParameterContainer(): void + public function testFluentSetParameterContainer(): void { self::assertSame($this->statement, $this->statement->setParameterContainer(new ParameterContainer())); } - /** - * @todo Implement testGetParameterContainer(). - */ - public function testGetParameterContainer(): void + public function testGetParameterContainerReturnsContainer(): void { $container = new ParameterContainer(); $this->statement->setParameterContainer($container); self::assertSame($container, $this->statement->getParameterContainer()); } - public function testGetResource(): void + public function testGetResourceReturnsPdoStatement(): void { $pdo = new SqliteMemoryPdo(); $stmt = $pdo->prepare('SELECT 1'); @@ -103,10 +100,7 @@ public function testGetSql(): void self::assertEquals('SELECT 1', $this->statement->getSql()); } - /** - * Test that prepare() returns the statement for method chaining - */ - public function testPrepare(): void + public function testFluentPrepare(): void { $this->statement->initialize(new SqliteMemoryPdo()); $result = $this->statement->prepare('SELECT 1'); @@ -114,7 +108,7 @@ public function testPrepare(): void self::assertSame($this->statement, $result); } - public function testIsPrepared(): void + public function testIsPreparedReturnsTrueAfterPrepare(): void { self::assertFalse($this->statement->isPrepared()); $this->statement->initialize(new SqliteMemoryPdo()); @@ -122,7 +116,7 @@ public function testIsPrepared(): void self::assertTrue($this->statement->isPrepared()); } - public function testExecute(): void + public function testExecuteReturnsResult(): void { $this->statement->setDriver(new TestPdo(new TestConnection($pdo = new SqliteMemoryPdo()))); $this->statement->initialize($pdo); diff --git a/test/unit/Adapter/Platform/Sql92Test.php b/test/unit/Adapter/Platform/Sql92Test.php index ce2cde61..f1820b15 100644 --- a/test/unit/Adapter/Platform/Sql92Test.php +++ b/test/unit/Adapter/Platform/Sql92Test.php @@ -81,7 +81,7 @@ public function testQuoteValueRaisesNoticeWithoutPlatformSupport(): void $this->platform->quoteValue('value'); } - public function testQuoteValue(): void + public function testQuoteValueThrowsWithoutDriver(): void { $this->expectException(VunerablePlatformQuoteException::class); self::assertEquals("'value'", @$this->platform->quoteValue('value')); @@ -96,7 +96,7 @@ public function testQuoteValue(): void ); } - public function testQuoteTrustedValue(): void + public function testQuoteTrustedValueEscapesSpecialCharacters(): void { self::assertEquals("'value'", $this->platform->quoteTrustedValue('value')); self::assertEquals("'Foo O\\'Bar'", $this->platform->quoteTrustedValue("Foo O'Bar")); @@ -112,7 +112,7 @@ public function testQuoteTrustedValue(): void ); } - public function testQuoteValueList(): void + public function testQuoteValueListThrowsWithoutDriver(): void { $this->expectException(VunerablePlatformQuoteException::class); self::assertEquals("'Foo O\\'Bar'", $this->platform->quoteValueList("Foo O'Bar")); diff --git a/test/unit/Adapter/Profiler/ProfilerTest.php b/test/unit/Adapter/Profiler/ProfilerTest.php index e671e857..289b4c0f 100644 --- a/test/unit/Adapter/Profiler/ProfilerTest.php +++ b/test/unit/Adapter/Profiler/ProfilerTest.php @@ -33,7 +33,7 @@ protected function setUp(): void $this->profiler = new Profiler(); } - public function testProfilerStart(): void + public function testFluentProfilerStartWithStringAndContainer(): void { $ret = $this->profiler->profilerStart('SELECT * FROM FOO'); self::assertSame($this->profiler, $ret); @@ -44,7 +44,7 @@ public function testProfilerStart(): void $this->profiler->profilerStart(5); } - public function testProfilerFinish(): void + public function testProfilerFinishThrowsWithoutStart(): void { $this->profiler->profilerStart('SELECT * FROM FOO'); $ret = $this->profiler->profilerFinish(); @@ -56,7 +56,7 @@ public function testProfilerFinish(): void $profiler->profilerFinish(); } - public function testGetLastProfile(): void + public function testGetLastProfileReturnsSqlAndTimings(): void { $this->profiler->profilerStart('SELECT * FROM FOO'); $this->profiler->profilerFinish(); @@ -68,7 +68,7 @@ public function testGetLastProfile(): void self::assertIsFloat($profile['elapse']); } - public function testGetProfiles(): void + public function testGetProfilesReturnsAllRecordedProfiles(): void { $this->profiler->profilerStart('SELECT * FROM FOO1'); $this->profiler->profilerFinish(); From 530a55ea89998a75c29dcffca4f7219d3590005b Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Thu, 26 Mar 2026 09:52:29 +1100 Subject: [PATCH 4/5] Fix phpcs violations: alignment, FQCN imports, unused imports --- .../AbstractAdapterInterfaceFactoryTest.php | 6 +-- test/unit/Adapter/ParameterContainerTest.php | 1 - test/unit/ResultSet/AbstractResultSetTest.php | 4 +- .../unit/ResultSet/HydratingResultSetTest.php | 5 ++- test/unit/Sql/AbstractSqlTest.php | 39 +++++++++++-------- test/unit/Sql/Platform/PlatformTest.php | 6 ++- test/unit/Sql/SqlTest.php | 7 ++-- 7 files changed, 39 insertions(+), 29 deletions(-) diff --git a/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php b/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php index 6de74955..577fe7bc 100644 --- a/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php +++ b/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php @@ -173,15 +173,15 @@ public function testInvokeUsesResultSetFromContainer(): void $profiler = $this->createMock(ProfilerInterface::class); /** @var PdoDriverInterface&MockObject $driverMock */ - $driverMock = $this->createMock(PdoDriverInterface::class); + $driverMock = $this->createMock(PdoDriverInterface::class); /** @var PlatformInterface&MockObject $platformMock */ $platformMock = $this->createMock(PlatformInterface::class); $container = new ServiceManager([ 'abstract_factories' => [AbstractAdapterInterfaceFactory::class], 'factories' => [ - PdoStubDriver::class => static fn() => $driverMock, - PlatformInterface::class => static fn() => $platformMock, + PdoStubDriver::class => static fn() => $driverMock, + PlatformInterface::class => static fn() => $platformMock, ResultSetInterface::class => static fn() => $resultSet, ProfilerInterface::class => static fn() => $profiler, ], diff --git a/test/unit/Adapter/ParameterContainerTest.php b/test/unit/Adapter/ParameterContainerTest.php index 6ff1ae5f..d80c00d8 100644 --- a/test/unit/Adapter/ParameterContainerTest.php +++ b/test/unit/Adapter/ParameterContainerTest.php @@ -312,7 +312,6 @@ public function testOffsetSetThrowsOnInvalidKeyType(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Keys must be string, integer or null'); - /** @phpstan-ignore argument.type */ $container->offsetSet(1.5, 'value'); } diff --git a/test/unit/ResultSet/AbstractResultSetTest.php b/test/unit/ResultSet/AbstractResultSetTest.php index 79148dc4..38476880 100644 --- a/test/unit/ResultSet/AbstractResultSetTest.php +++ b/test/unit/ResultSet/AbstractResultSetTest.php @@ -14,12 +14,12 @@ use PhpDb\Adapter\Driver\Pdo\Result; use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\ResultSet\AbstractResultSet; -use PhpDb\ResultSet\Exception\InvalidArgumentException; use PhpDb\ResultSet\Exception\RuntimeException; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use stdClass; use TypeError; use function assert; @@ -584,7 +584,7 @@ public function testCountReturnsCachedResult(): void public function testToArrayThrowsOnNonCastableRows(): void { $resultSet = $this->createResultSetMock(); - $resultSet->initialize(new ArrayIterator([new \stdClass()])); + $resultSet->initialize(new ArrayIterator([new stdClass()])); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('cannot be cast to an array'); diff --git a/test/unit/ResultSet/HydratingResultSetTest.php b/test/unit/ResultSet/HydratingResultSetTest.php index 06f80af9..dc63d268 100644 --- a/test/unit/ResultSet/HydratingResultSetTest.php +++ b/test/unit/ResultSet/HydratingResultSetTest.php @@ -10,6 +10,7 @@ use Laminas\Hydrator\ArraySerializableHydrator; use Laminas\Hydrator\ClassMethodsHydrator; use Override; +use PhpDb\ResultSet\Exception\RuntimeException; use PhpDb\ResultSet\HydratingResultSet; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; @@ -204,13 +205,13 @@ public function testToArrayUsesHydratorExtract(): void public function testCurrentDisablesBufferingImplicitly(): void { $hydratingRs = new HydratingResultSet(); - $hydratingRs->initialize(new \ArrayIterator([ + $hydratingRs->initialize(new ArrayIterator([ ['id' => 1], ])); $hydratingRs->current(); - $this->expectException(\PhpDb\ResultSet\Exception\RuntimeException::class); + $this->expectException(RuntimeException::class); $hydratingRs->buffer(); } } diff --git a/test/unit/Sql/AbstractSqlTest.php b/test/unit/Sql/AbstractSqlTest.php index e07aa106..ba288fe2 100644 --- a/test/unit/Sql/AbstractSqlTest.php +++ b/test/unit/Sql/AbstractSqlTest.php @@ -5,18 +5,25 @@ namespace PhpDbTest\Sql; use Override; +use PhpDb\Adapter\Adapter; use PhpDb\Adapter\Driver\DriverInterface; +use PhpDb\Adapter\Driver\StatementInterface; use PhpDb\Adapter\ParameterContainer; use PhpDb\Adapter\StatementContainer; use PhpDb\Sql\AbstractSql; use PhpDb\Sql\Argument; use PhpDb\Sql\Argument\Identifier; +use PhpDb\Sql\ArgumentInterface; +use PhpDb\Sql\ArgumentType; +use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\Expression; use PhpDb\Sql\ExpressionInterface; +use PhpDb\Sql\Join; use PhpDb\Sql\Predicate; use PhpDb\Sql\Select; use PhpDb\Sql\TableIdentifier; +use PhpDbTest\TestAsset\SelectDecorator; use PhpDbTest\TestAsset\TrustingSql92Platform; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; @@ -435,7 +442,7 @@ protected function invokeProcessExpressionMethod( */ public function testProcessJoinWithArrayAlias(): void { - $join = new \PhpDb\Sql\Join(); + $join = new Join(); $join->join(['b' => 'bar'], 'foo.id = b.foo_id'); $method = new ReflectionMethod($this->abstractSql, 'processJoin'); @@ -456,8 +463,8 @@ public function testProcessJoinWithArrayAlias(): void */ public function testProcessJoinWithTableIdentifier(): void { - $join = new \PhpDb\Sql\Join(); - $join->join(new \PhpDb\Sql\TableIdentifier('bar', 'myschema'), 'foo.id = bar.foo_id'); + $join = new Join(); + $join->join(new TableIdentifier('bar', 'myschema'), 'foo.id = bar.foo_id'); $method = new ReflectionMethod($this->abstractSql, 'processJoin'); $result = $method->invoke( @@ -478,7 +485,7 @@ public function testProcessJoinWithTableIdentifier(): void */ public function testProcessJoinWithPredicateExpressionOnClause(): void { - $join = new \PhpDb\Sql\Join(); + $join = new Join(); $join->join('bar', new Predicate\Expression('foo.id = bar.foo_id AND bar.active = 1')); $method = new ReflectionMethod($this->abstractSql, 'processJoin'); @@ -527,7 +534,7 @@ public function testRenderTableWithAlias(): void */ public function testProcessJoinWithExpressionNameViaArray(): void { - $join = new \PhpDb\Sql\Join(); + $join = new Join(); $join->join(['x' => new Expression('LATERAL(SELECT 1)')], 'true'); $method = new ReflectionMethod($this->abstractSql, 'processJoin'); @@ -548,7 +555,7 @@ public function testProcessJoinWithExpressionNameViaArray(): void public function testProcessJoinWithSelectSubqueryViaArray(): void { $subselect = new Select('bar'); - $join = new \PhpDb\Sql\Join(); + $join = new Join(); $join->join(['b' => $subselect], 'foo.id = b.foo_id'); $method = new ReflectionMethod($this->abstractSql, 'processJoin'); @@ -644,10 +651,10 @@ public function testCreateSqlFromSpecNonCombinedByThrowsOnUnsupportedCount(): vo public function testProcessExpressionThrowsOnUnknownArgumentType(): void { - $unknownArg = new class implements \PhpDb\Sql\ArgumentInterface { - public function getType(): \PhpDb\Sql\ArgumentType + $unknownArg = new class implements ArgumentInterface { + public function getType(): ArgumentType { - return \PhpDb\Sql\ArgumentType::Value; + return ArgumentType::Value; } public function getValue(): string @@ -663,7 +670,7 @@ public function getSpecification(): string $expression = new Expression('?', [$unknownArg]); - $this->expectException(\PhpDb\Sql\Exception\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Unknown argument type'); $this->invokeProcessExpressionMethod($expression); } @@ -679,11 +686,11 @@ public function testResolveColumnValueWithNamedParameterPrefix(): void ->willReturnCallback(fn(string $name): string => ':' . $name); $parameterContainer = new ParameterContainer(); - $mockStatement = $this->createMock(\PhpDb\Adapter\Driver\StatementInterface::class); + $mockStatement = $this->createMock(StatementInterface::class); $mockStatement->method('getParameterContainer')->willReturn($parameterContainer); $mockStatement->method('setSql')->willReturnSelf(); - $adapter = $this->getMockBuilder(\PhpDb\Adapter\Adapter::class) + $adapter = $this->getMockBuilder(Adapter::class) ->setConstructorArgs([$mockDriver, new TrustingSql92Platform()]) ->getMock(); $adapter->method('getDriver')->willReturn($mockDriver); @@ -696,7 +703,7 @@ public function testResolveColumnValueWithNamedParameterPrefix(): void public function testLocalizeVariablesCopiesSubjectProperties(): void { - $decorator = new \PhpDbTest\TestAsset\SelectDecorator(); + $decorator = new SelectDecorator(); $select = new Select('users'); $select->columns(['id', 'name']); $decorator->setSubject($select); @@ -709,7 +716,7 @@ public function testLocalizeVariablesCopiesSubjectProperties(): void public function testProcessSubSelectUsesDecoratorWhenPlatformDecorator(): void { - $decorator = new \PhpDbTest\TestAsset\SelectDecorator(); + $decorator = new SelectDecorator(); $outer = new Select('foo'); $outer->where(['x' => new Select('bar')]); @@ -741,10 +748,10 @@ public function testFlattenExpressionValuesViaInPredicateWithParameterContainer( ->willReturnCallback(fn(string $name): string => ':' . $name); $parameterContainer = new ParameterContainer(); - $mockStatement = $this->createMock(\PhpDb\Adapter\Driver\StatementInterface::class); + $mockStatement = $this->createMock(StatementInterface::class); $mockStatement->method('getParameterContainer')->willReturn($parameterContainer); - $adapter = $this->getMockBuilder(\PhpDb\Adapter\Adapter::class) + $adapter = $this->getMockBuilder(Adapter::class) ->setConstructorArgs([$mockDriver, new TrustingSql92Platform()]) ->getMock(); $adapter->method('getDriver')->willReturn($mockDriver); diff --git a/test/unit/Sql/Platform/PlatformTest.php b/test/unit/Sql/Platform/PlatformTest.php index 027f6ddf..ae6066dc 100644 --- a/test/unit/Sql/Platform/PlatformTest.php +++ b/test/unit/Sql/Platform/PlatformTest.php @@ -11,6 +11,8 @@ use PhpDb\Adapter\StatementContainer; use PhpDb\ResultSet\ResultSet; use PhpDb\Sql\Exception\RuntimeException; +use PhpDb\Sql\Insert; +use PhpDb\Sql\Platform\AbstractPlatform; use PhpDb\Sql\Platform\Platform; use PhpDb\Sql\Platform\PlatformDecoratorInterface; use PhpDb\Sql\PreparableSqlInterface; @@ -245,7 +247,7 @@ public function testGetTypeDecoratorFallsThroughWhenNoMatch(): void $platform = new Platform($adapterPlatform); $decorator = $this->createMock(PlatformDecoratorInterface::class); - $platform->setTypeDecorator(\PhpDb\Sql\Insert::class, $decorator); + $platform->setTypeDecorator(Insert::class, $decorator); $select = new Select('foo'); $result = $platform->getTypeDecorator($select); @@ -258,7 +260,7 @@ public function testGetTypeDecoratorMatchesByInstanceofLoop(): void $adapterPlatform = new TestAsset\TrustingSql92Platform(); $platform = new Platform($adapterPlatform); - $innerPlatform = new \PhpDb\Sql\Platform\AbstractPlatform(); + $innerPlatform = new AbstractPlatform(); $platform->setTypeDecorator(SqlInterface::class, $innerPlatform); $select = new Select('foo'); diff --git a/test/unit/Sql/SqlTest.php b/test/unit/Sql/SqlTest.php index f9ff635a..c83b8a3e 100644 --- a/test/unit/Sql/SqlTest.php +++ b/test/unit/Sql/SqlTest.php @@ -10,12 +10,13 @@ use PhpDb\Adapter\Driver\DriverInterface; use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\Adapter\Driver\StatementInterface; +use PhpDb\Adapter\Platform\PlatformInterface; use PhpDb\Sql\Delete; use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\Insert; -use PhpDb\Sql\Select; use PhpDb\Sql\Platform\PlatformDecoratorInterface; +use PhpDb\Sql\Select; use PhpDb\Sql\Sql; use PhpDb\Sql\TableIdentifier; use PhpDb\Sql\Update; @@ -206,7 +207,7 @@ public function testGetSqlPlatformReturnsPlatformDecorator(): void public function testPrepareStatementThrowsWhenPlatformNotPreparable(): void { $decorator = $this->createMock(PlatformDecoratorInterface::class); - $platform = $this->createMock(\PhpDb\Adapter\Platform\PlatformInterface::class); + $platform = $this->createMock(PlatformInterface::class); $platform->method('getSqlPlatformDecorator')->willReturn($decorator); $adapter = $this->getMockBuilder(Adapter::class) @@ -227,7 +228,7 @@ public function testPrepareStatementThrowsWhenPlatformNotPreparable(): void public function testBuildSqlStringThrowsWhenPlatformNotSqlInterface(): void { $decorator = $this->createMock(PlatformDecoratorInterface::class); - $platform = $this->createMock(\PhpDb\Adapter\Platform\PlatformInterface::class); + $platform = $this->createMock(PlatformInterface::class); $platform->method('getSqlPlatformDecorator')->willReturn($decorator); $adapter = $this->getMockBuilder(Adapter::class) From c8936adcdcc47b296d78c54982efcdf7a3761981 Mon Sep 17 00:00:00 2001 From: Simon Mundy <46739456+simon-mundy@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:07:21 +1100 Subject: [PATCH 5/5] Delete REFACTOR.md Signed-off-by: Simon Mundy <46739456+simon-mundy@users.noreply.github.com> --- REFACTOR.md | 106 ---------------------------------------------------- 1 file changed, 106 deletions(-) delete mode 100644 REFACTOR.md diff --git a/REFACTOR.md b/REFACTOR.md deleted file mode 100644 index b6546d87..00000000 --- a/REFACTOR.md +++ /dev/null @@ -1,106 +0,0 @@ -# Test Coverage & Refactoring Log - -## Current State (2026-03-25) - -- **Tests:** 1439 passing, 0 skipped, 0 warnings -- **Coverage:** 99.13% lines (3289/3318), up from 84% -- **Sql/\*:** 100% (1649/1649) -- **ResultSet/\*:** 100% (121/121) -- **Metadata/\*:** 100% (204/204) -- **Container/\*:** 100% (83/83) -- **Adapter/\*:** remaining gap — 29 uncovered lines across 11 files - ---- - -## Completed Work - -### Dead Code Removed - -| File | What was removed | Reason | -|---|---|---| -| `Sql/AbstractSql.php` | `processValuesArgument()` method + `Values` match arm (~29 lines) | `flattenExpressionValues()` always expands Values before the match fires | -| `Sql/AbstractSql.php` | Defensive throw in `processJoin()` for invalid name type (~5 lines) | Type system on `Join::join()` prevents invalid types | -| `SelectTest.php` | `testBadJoinName` test | Tested the removed throw | -| `ResultSet/AbstractResultSet.php` | Throw in `initialize()` for invalid data source (3 lines) | PHP 8 `iterable` = `array\|Traversable`, both handled | -| `ResultSet/AbstractResultSet.php` | Non-Iterator branch in `valid()` (3 lines) | `initialize()` always stores an Iterator | -| `ResultSet/AbstractResultSet.php` | Non-Iterator branch in `rewind()` (2 lines) | Same reason | - -### Skipped Tests Removed (9 total) - -| Test | Reason for removal | -|---|---| -| `AdapterInterfaceDelegatorTest::testDelegatorWithPluginManager` | `$options` param is dead code in delegator | -| `ConnectionTest::testResource` | Required concrete driver DSN building that doesn't exist | -| `ConnectionTest::testArrayOfConnectionParametersCreatesCorrectDsn` | Required MySQL-specific DSN building | -| `ConnectionTest::testHostnameAndUnixSocketThrowsInvalidConnectionParametersException` | Required MySQL parameter validation | -| `ConnectionTest::testDblibArrayOfConnectionParametersCreatesCorrectDsn` | Required Dblib-specific DSN building | -| `PlatformTest::testAbstractPlatformCrashesGracefullyOnMissingDefaultPlatform` | Empty stub, readonly skip reason outdated | -| `PlatformTest::testAbstractPlatformCrashesGracefullyOnMissingDefaultPlatformWithGetDecorators` | Empty stub, readonly skip reason outdated | -| `PredicateTest::testCanCreateExpressionsWithoutAnyBoundSqlParameters` | Contradictory logic, behaviour covered elsewhere | -| `MetadataFeatureTest::testPostInitialize` | Redundant, 6 other tests cover same behaviour | - -### Anonymous Classes Replaced with TestAssets - -| TestAsset | Replaces | Location | -|---|---|---| -| `TestSql92Platform` | 3 anonymous Sql92 subclasses | `test/unit/TestAsset/` | -| `TestTableGatewayFeature` | 7 anonymous TG features | `test/unit/TableGateway/Feature/TestAsset/` | -| `TestRowGatewayFeature` | 3 anonymous RG features | `test/unit/RowGateway/Feature/TestAsset/` | -| `TestDriverFeature` | 1 anonymous Driver\Feature\AbstractFeature | `test/unit/Adapter/Driver/Feature/TestAsset/` | -| `ConcreteTableObject` | 1 anonymous AbstractTableObject | `test/unit/Metadata/Object/TestAsset/` | -| `TestTableGateway` | 1 anonymous TableGateway | `test/unit/TableGateway/Feature/TestAsset/` | -| `TestPluginManager` | 1 anonymous AbstractPluginManager | `test/unit/Adapter/Container/TestAsset/` | -| `TestFeatureDriver` | 1 anonymous DriverInterface+Trait impl | `test/unit/Adapter/Driver/TestAsset/` | -| `IncompleteSource` | (new) for testing incomplete subclass | `test/unit/Metadata/Source/TestAsset/` | - -`ConcreteAdapterAwareObject` (pre-existing) replaced 2 anonymous AdapterAwareTrait classes. -`Sql\Platform\AbstractPlatform` used directly (not abstract despite name). - -### CoversMethod Fixes - -- Removed invalid `Adapter::createDriver`, `Adapter::createPlatform` (methods deleted) -- Removed invalid `Join::__construct` (no constructor) -- Removed invalid `AbstractSql::processExpressionValue` (method deleted) -- Removed invalid `Argument::__construct` etc. (factory class, no such methods) -- Fixed `ConnectionTransactionsTest` — removed `()` from method name strings - -### Infrastructure - -- PCOV removed, Xdebug installed for PHP 8.1/8.3/8.4 -- `xdebug.mode=coverage` configured in `conf.d/ext-xdebug.ini` for all versions - ---- - -## Remaining Work: Adapter/\* to 100% - -29 uncovered lines across 11 files. All are in `src/Adapter/`. - -### Files with uncovered lines - -| File | Covered | Uncovered lines | What needs testing | -|---|---|---|---| -| `Adapter.php` | 58/61 | 38, 158, 163 | L38: `setProfiler` delegation when driver is `ProfilerAwareInterface`. L158/163: closures returned by `getHelpers()` need to be called, not just returned | -| `AdapterAwareTrait.php` | 0/2 | 12, 14 | `setDbAdapter()` body. Likely a `#[CoversMethod]` attribution issue — `AdapterAwareTraitTest` calls it but may not list it | -| `ParameterContainer.php` | 68/85 | 131, 140, 151, 177, 199, 212, 215, 226, 239, 242, 263, 276, 279, 290, 303, 306, 338 | L131: `offsetSet` with int name not in positions. L140: nameMapping match. L151: invalid key throw. L177: `offsetUnset` positions. L199-338: maxlength/errata method branches. L338: `getPositionalArray`. Most are likely `#[CoversMethod]` attribution — check if methods are listed | -| `Driver/AbstractConnection.php` | 4/14 | 45, 51, 56, 65, 66, 69, 81, 83, 90, 92 | ALL methods uncovered. Need `test/unit/Adapter/Driver/AbstractConnectionTest.php` — use `ConnectionWrapper` test asset | -| `Driver/Feature/DriverFeatureProviderTrait.php` | 8/13 | 30, 31, 32, 33, 34 | L30-34: `addFeature` throw when trait not in DriverInterface. Create a class using the trait WITHOUT implementing DriverInterface | -| `Driver/Pdo/AbstractPdo.php` | 26/41 | 47, 100, 138-157 | L47: constructor `addFeatures`. L100: `createStatement` with PDOStatement. L138-157: `formatParameterName` branches — int name with NAMED type, positional return | -| `Driver/Pdo/AbstractPdoConnection.php` | 43/52 | 73-75, 101, 119, 171, 192-196 | L73-75: `getDsn` throws (user already added this test via linter). L101/119/171: auto-connect in beginTransaction/commit/execute (user already added these). L192-196: `prepare` auto-connect and delegate (user already added) | -| `Driver/Pdo/Result.php` | 41/48 | 109, 125-128, 131, 238 | L109: `buffer()` empty body. L125-128: `setFetchMode` invalid throw. L131: valid setFetchMode assignment. L238: `valid()` return. Likely `#[CoversMethod]` issues — check if methods are listed | -| `Driver/Pdo/Statement.php` | 73/87 | 50, 125, 161-162, 186, 205, 213-218, 238, 254 | L50: constructor body. L125: prepare-already-prepared throw. L161-162: execute param merging. L186: error code cast. L205: bindParams early return. L213-218: errata type matching. L238: positional binding. L254: clone. Many likely `#[CoversMethod]` issues | -| `Platform/AbstractPlatform.php` | 32/38 | 126-130, 132 | `quoteValue()` without driver throws `VunerablePlatformQuoteException`. Already tested in `Sql92Test::testQuoteValue` but may not be attributed to `AbstractPlatform` | -| `Profiler/Profiler.php` | 27/30 | 45, 46, 47 | `profilerStart` throw on invalid target type. May be tested via TypeError already due to type hints | - -### Strategy for remaining work - -Many of these uncovered lines fall into two categories: - -1. **`#[CoversMethod]` attribution gaps** — the code IS executed by tests but the test class doesn't list the method in its CoversMethod attributes, so coverage isn't credited. Fix: add missing attributes. - -2. **Actually untested branches** — specific code paths not exercised. Fix: add targeted tests. - -Start by checking CoversMethod on each test file (ParameterContainerTest, ResultTest, StatementTest, Sql92Test, ProfilerTest, AdapterAwareTraitTest). Many lines will light up just by adding the attribute. - -For `AbstractConnection`, `AbstractPdo`, and `DriverFeatureProviderTrait`, actual new tests are needed since the existing tests don't exercise the uncovered paths. - -The `ConnectionTest.php` file was updated by the user with tests for getDsn throw, beginTransaction/commit/execute auto-connect, and prepare — these should cover most of `AbstractPdoConnection.php` when the Clover report is regenerated.