diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dda3655..23c2d38ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ - Enh #925, #951: Add callback to `Query::all()` and `Query::one()` methods (@Tigrov, @vjik) - New #954: Add `DbArrayHelper::arrange()` method (@Tigrov) - Chg #956: Remove nullable from `PdoConnectionInterface::getActivePdo()` result (@vjik) +- Enh #941: Add the ability for user-defined type casting (@Tigrov) - Enh #822: Refactor data readers (@Tigrov) ## 1.3.0 March 21, 2024 diff --git a/UPGRADE.md b/UPGRADE.md index feef1ea0d..84eff9f7e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -207,3 +207,4 @@ Each table column has its own class in the `Yiisoft\Db\Schema\Column` namespace - Remove `null` from return type of `getQuery()` method in `BatchQueryResultInterface` and `BatchQueryResult` class; - Remove parameters from `each()` method in `QueryInterface` and `Query` class; - Change return type of `each()` method to `DataReaderInterface` in `QueryInterface` and `Query` class; +- Add `$columnFactory` parameter to `AbstractPdoConnection::__construct()` constructor; diff --git a/docs/guide/en/README.md b/docs/guide/en/README.md index 2fe6ece88..9fe14203e 100644 --- a/docs/guide/en/README.md +++ b/docs/guide/en/README.md @@ -137,6 +137,7 @@ via schema: - [Reading database schema](schema/usage.md) - [Configuring schema cache](schema/cache.md) +- [Casting values](schema/typecasting.md) ## Extensions diff --git a/docs/guide/en/schema/typecasting.md b/docs/guide/en/schema/typecasting.md new file mode 100644 index 000000000..f6349741b --- /dev/null +++ b/docs/guide/en/schema/typecasting.md @@ -0,0 +1,336 @@ +# Type casting values + +Type casting is the process of converting a value from one data type to another. In the context of the database, +type casting is used to ensure that values are saved and retrieved in the correct type. + +```mermaid +flowchart LR + phpType[PHP Type] + dbType[Database Type] + + phpType --> dbType + dbType --> phpType +``` + +## Casting values to be saved in the database + +When saving a value to the database, the value must be in the correct type. For example, if saving a value to a column +that is of type `bit`, the value must be an `integer` or `string` depends on DBMS. + +To ensure that the value is saved in the correct type, `ColumnInterface::dbTypecast()` method can be used to cast +the value. Majority of the DB library methods, such as `CommandInterface::insert()`, automatically convert the type. + +```php +use Yiisoft\Db\Connection\ConnectionInterface; + +/** @var ConnectionInterface $db */ +$command = $db->createCommand(); +$command->insert('customer', [ + 'name' => 'John Doe', + 'is_active' => true, +]); +$command->execute(); +``` + +In the example above, the value of `is_active` is a `boolean`, but the column `is_active` can be of type `bit`. +The `CommandInterface::insert()` method will automatically cast the value to the correct type. + +## Casting values retrieved from the database + +When you retrieve a value from the database, the value can be returned in a different type than you expect. +For example, a value that is stored as a `numeric(5,2)` in the database will be returned as a `string`. This is because +the database driver does not convert some data types when retrieves values. + +To ensure that the value is returned in the correct type, you can use `ColumnInterface::phpTypecast()` method to cast +the value, in the example above, to a `float`. + +```php +use Yiisoft\Db\Connection\ConnectionInterface; + +/** @var ConnectionInterface $db */ +$command = $db->createCommand('SELECT * FROM {{customer}} WHERE id = 1'); + +$row = $command->queryOne(); +$isActive = $row['is_active']; + +// Cast the value to the correct type +$isActive = $db->getTableSchema('customer')->getColumn('is_active')->phpTypecast($isActive); +``` + +In the example above, the value of `is_active` can be retrieved from the database as a `bit`, but the correct PHP type +is `boolean`. The `ColumnInterface::phpTypecast()` method is used to cast the value to the correct type. + +## Custom type casting + +To implement custom type casting you need to extend the `AbstractColumn` class and override the `dbTypecast()` +and `phpTypecast()` methods. + +For example, in Postgres database, the `point` type is represented as a string in the format `(x,y)`. To cast the value +to a `Point` class, you can create a custom column class and override the `dbTypecast()` and `phpTypecast()`. + +```php +use Yiisoft\Db\Expression\Expression; +use Yiisoft\Db\Expression\ExpressionInterface; +use Yiisoft\Db\Schema\Column\AbstractColumn; + +final class PointColumn extends AbstractColumn +{ + /** + * @var string The default column abstract type + */ + protected const DEFAULT_TYPE = 'point'; + + /** + * @param ExpressionInterface|Point|string|null $value + */ + public function dbTypecast(mixed $value): ExpressionInterface|string|null + { + if ($value instanceof Point) { + return new Expression('(:x,:y)', ['x' => $value->getX(), 'y' => $value->getY()]); + } + + return $value; + } + + /** + * @param string|null $value + */ + public function phpTypecast(mixed $value): Point|null + { + if (is_string($value)) { + [$x, $y] = explode(',', substr($value, 1, -1)); + + return new Point((float) $x, (float) $y); + } + + return $value; + } +} + +class Point +{ + public function __construct( + private float $x, + private float $y, + ) { + } + + public function getX(): float + { + return $this->x; + } + + public function getY(): float + { + return $this->y; + } +} +``` + +Then use the custom column class in the database connection configuration. + +```php +use Yiisoft\Db\Pgsql\Column\ColumnFactory; +use Yiisoft\Db\Pgsql\Connection; + +$columnFactory = new ColumnFactory( + 'columnClassMap' => [ + // It is necessary to define the column class map for the custom abstract type + // abstract type => class name + 'point' => PointColumn::class, + ], + 'typeMap' => [ + // It is necessary to define the type map for the database type + // database type => abstract type + 'point' => 'point', + ], +); + +// Create a database connection with the custom column factory +$db = new Connection($pdoDriver, $schemaCache, $columnFactory); +``` + +In the example above, the `PointColumn` class is used to cast the `point` database type to the `Point` class. +The `Point` class is used to represent the `point` type as an object with `x` and `y` properties. + +> [!WARNING] +> If you use different custom type casting for different database connections, you need also use different schema +> cache for such connections. + +## Lazy type casting + +Lazy type casting is a way to defer the type casting of a value until it is accessed. This can be useful when you want +to avoid the overhead of type casting for values that are not used. + +Here is an example how to configure lazy type casting for the `array`, `json` and `structured` database types. + +```php +use Yiisoft\Db\Constant\ColumnType; +use Yiisoft\Db\Pgsql\Column\ColumnFactory; +use Yiisoft\Db\Pgsql\Connection; +use Yiisoft\Db\Schema\Column\ArrayLazyColumn; +use Yiisoft\Db\Schema\Column\StructuredLazyColumn; +use Yiisoft\Db\Schema\Data\JsonLazyArray; + +$columnFactory = new ColumnFactory( + 'columnClassMap' => [ + ColumnType::ARRAY => ArrayLazyColumn::class, // converts values to `LazyArray` objects + ColumnType::JSON => JsonLazyColumn::class, // converts values to `JsonLazyArray` objects + ColumnType::STRUCTURED => StructuredLazyColumn::class, // converts values to `StructuredLazyArray` objects + ], +); + +// Create a database connection with the custom column factory +$db = new Connection($pdoDriver, $schemaCache, $columnFactory); + +/** @var JsonLazyArray $tags `tags` column is of database type `json` */ +$tags = $db->getTableSchema('customer')->getColumn('tags')->phpTypecast($row['tags']); + +foreach ($tags as $tag) { + echo $tag; +} +``` + +## Structured data types + +Some databases support structured data types, such as `composite` types in Postgres. To cast a structured data type to +a custom class, you need to create a column class which extends `AbstractStructuredColumn` and override +the `phpTypecast()` method. + +For example if `currency_money` and `file_with_name` are defined composite types in Postgres as follows: + +```sql +CREATE TYPE currency_money AS ( + value decimal(10,2), + currency_code char(3) +); +``` + +```sql +CREATE TYPE file_with_name AS ( + path text, + name text +); +``` + +you can create `MyStructuredColumn` column class to cast a value to `CurrencyMoney` or `FileWithName` classes. + +```php +use Yiisoft\Db\Expression\Expression; +use Yiisoft\Db\Expression\ExpressionInterface; +use Yiisoft\Db\Pgsql\Data\StructuredParser; + +final class MyStructuredColumn extends AbstractStructuredColumn +{ + /** + * @param array|object|string|null $value + */ + public function dbTypecast(mixed $value): ExpressionInterface|null + { + if ($value === null || $value instanceof ExpressionInterface) { + return $value; + } + + if ($this->getDbType() === 'file_with_name' && $value instanceof FileWithName) { + return new StructuredExpression([$value->getPath(), $value->getName()], $this); + } + + return new StructuredExpression($value, $this); + } + + /** + * @param string|null $value + */ + public function phpTypecast(mixed $value): CurrencyMoney|StructuredLazyArray|null + { + if (is_string($value)) { + $value = new StructuredLazyArray($value, $this->getColumns()); + + return match ($this->getDbType()) { + 'currency_money' => new CurrencyMoney(...$value->getValue()), + 'file_with_name' => new FileWithName(...$value->getValue()), + default => $value, + }; + } + + return $value; + } +} + +final class CurrencyMoney implements \JsonSerializable, \IteratorAggregate +{ + public function __construct( + private float $value, + private string $currencyCode = 'USD', + ) { + } + + public function getCurrencyCode(): string + { + return $this->currencyCode; + } + + public function getValue(): float + { + return $this->value; + } + + /** + * `JsonSerializable` interface is implemented to convert the object to a database representation. + */ + public function jsonSerialize(): array + { + return [ + 'value' => $this->value, + 'currency_code' => $this->currencyCode, + ]; + } + + /** + * Alternatively, `IteratorAggregate` interface can be implemented to convert the object to a database representation. + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator([ + 'value' => $this->value, + 'currency_code' => $this->currencyCode, + ]); + } +} + +final class FileWithName +{ + public function __construct( + private string $path, + private string $name, + ) { + } + + public function getPath(): string + { + return $this->path; + } + + public function getName(): string + { + return $this->name; + } +} +``` + +Then use the column class in the database connection configuration. + +```php +use Yiisoft\Db\Constant\ColumnType; +use Yiisoft\Db\Pgsql\Column\ColumnFactory; +use Yiisoft\Db\Pgsql\Connection; + +$columnFactory = new ColumnFactory( + 'columnClassMap' => [ + ColumnType::STRUCTURED => MyStructuredColumn::class, + ], +); + +// Create a database connection with the custom column factory +$db = new Connection($pdoDriver, $schemaCache, $columnFactory); +``` diff --git a/src/Driver/Pdo/AbstractPdoConnection.php b/src/Driver/Pdo/AbstractPdoConnection.php index 6e716d551..2fe931284 100644 --- a/src/Driver/Pdo/AbstractPdoConnection.php +++ b/src/Driver/Pdo/AbstractPdoConnection.php @@ -20,6 +20,7 @@ use Yiisoft\Db\Profiler\ProfilerAwareInterface; use Yiisoft\Db\Profiler\ProfilerAwareTrait; use Yiisoft\Db\QueryBuilder\QueryBuilderInterface; +use Yiisoft\Db\Schema\Column\ColumnFactoryInterface; use Yiisoft\Db\Schema\QuoterInterface; use Yiisoft\Db\Schema\SchemaInterface; use Yiisoft\Db\Transaction\TransactionInterface; @@ -50,8 +51,11 @@ abstract class AbstractPdoConnection extends AbstractConnection implements PdoCo protected QuoterInterface|null $quoter = null; protected SchemaInterface|null $schema = null; - public function __construct(protected PdoDriverInterface $driver, protected SchemaCache $schemaCache) - { + public function __construct( + protected PdoDriverInterface $driver, + protected SchemaCache $schemaCache, + protected ColumnFactoryInterface|null $columnFactory = null, + ) { } /** diff --git a/src/Schema/Column/AbstractColumnFactory.php b/src/Schema/Column/AbstractColumnFactory.php index 59d837a43..7dc5102b9 100644 --- a/src/Schema/Column/AbstractColumnFactory.php +++ b/src/Schema/Column/AbstractColumnFactory.php @@ -4,6 +4,7 @@ namespace Yiisoft\Db\Schema\Column; +use Closure; use Yiisoft\Db\Constant\ColumnType; use Yiisoft\Db\Constant\PseudoType; use Yiisoft\Db\Expression\Expression; @@ -12,6 +13,7 @@ use function array_diff_key; use function array_key_exists; use function array_merge; +use function is_callable; use function is_numeric; use function preg_match; use function str_replace; @@ -23,22 +25,67 @@ * The default implementation of the {@see ColumnFactoryInterface}. * * @psalm-import-type ColumnInfo from ColumnFactoryInterface + * + * @psalm-type ColumnClassMap = array|Closure(ColumnType::*, ColumnInfo): (class-string|null)> + * @psalm-type TypeMap = array */ abstract class AbstractColumnFactory implements ColumnFactoryInterface { /** - * The mapping from physical column types (keys) to abstract column types (values). - * - * @var string[] + * @var string[] The mapping from physical column types (keys) to abstract column types (values). * * @psalm-var array */ protected const TYPE_MAP = []; + /** + * @param array $columnClassMap The mapping from abstract column types to the classes implementing them. Where + * array keys are abstract column types and values are corresponding class names or PHP callable with the following + * signature: `function (string $type, array &$info): string|null`. The callable should return the class name based + * on the abstract type and the column information or `null` if the class name cannot be determined. + * @param array $typeMap The mapping from physical column types to abstract column types. Where array keys + * are physical column types and values are corresponding abstract column types or PHP callable with the following + * signature: `function (string $dbType, array &$info): string|null`. The callable should return the abstract type + * based on the physical type and the column information or `null` if the abstract type cannot be determined. + * + * For example: + * + * ```php + * $classMap = [ + * ColumnType::ARRAY => ArrayLazyColumn::class, + * ColumnType::JSON => JsonLazyColumn::class, + * ]; + * + * $typeMap = [ + * 'json' => function (string $dbType, array &$info): string|null { + * if (str_ends_with($info['name'], '_ids')) { + * $info['column'] = new IntegerColumn(); + * return ColumnType::ARRAY; + * } + * + * return null; + * }, + * ]; + * + * $columnFactory = new ColumnFactory($classMap, $typeMap); + * ``` + * + * @psalm-param ColumnClassMap $columnClassMap + * @psalm-param TypeMap $typeMap + */ + public function __construct( + protected array $columnClassMap = [], + protected array $typeMap = [], + ) { + } + public function fromDbType(string $dbType, array $info = []): ColumnInterface { $info['dbType'] = $dbType; - $type = $info['type'] ?? $this->getType($dbType, $info); + /** @psalm-var ColumnType::* $type */ + $type = $info['type'] + ?? $this->mapType($this->typeMap, $dbType, $info) + ?? $this->getType($dbType, $info); return $this->fromType($type, $info); } @@ -118,7 +165,9 @@ public function fromType(string $type, array $info = []): ColumnInterface $type = ColumnType::ARRAY; } - $columnClass = $this->getColumnClass($type, $info); + /** @psalm-var class-string $columnClass */ + $columnClass = $this->mapType($this->columnClassMap, $type, $info) + ?? $this->getColumnClass($type, $info); $column = new $columnClass($type, ...$info); @@ -139,8 +188,7 @@ protected function columnDefinitionParser(): ColumnDefinitionParser /** * @psalm-param ColumnType::* $type - * @param ColumnInfo $info - * + * @psalm-param ColumnInfo $info * @psalm-return class-string */ protected function getColumnClass(string $type, array $info = []): string @@ -192,7 +240,7 @@ protected function getType(string $dbType, array $info = []): string */ protected function isDbType(string $dbType): bool { - return isset(static::TYPE_MAP[$dbType]); + return isset(static::TYPE_MAP[$dbType]) || !($this->isType($dbType) || $this->isPseudoType($dbType)); } /** @@ -243,10 +291,37 @@ protected function isType(string $type): bool ColumnType::ARRAY, ColumnType::STRUCTURED, ColumnType::JSON => true, - default => false, + default => isset($this->columnClassMap[$type]), }; } + /** + * Maps a type to a value using a mapping array. + * + * @param array $map The mapping array. + * @param string $type The type to map. + * @param array $info The column information. + * + * @return string|null The mapped value or `null` if the type is not corresponding to any value. + * + * @psalm-param ColumnInfo $info + * @psalm-assert ColumnInfo $info + */ + protected function mapType(array $map, string $type, array &$info = []): string|null + { + if (!isset($map[$type])) { + return null; + } + + if (is_callable($map[$type])) { + /** @var string|null */ + return $map[$type]($type, $info); + } + + /** @var string */ + return $map[$type]; + } + /** * Converts column's default value according to {@see ColumnInterface::getPhpType()} after retrieval from the * database. diff --git a/tests/AbstractColumnFactoryTest.php b/tests/AbstractColumnFactoryTest.php index 75d213d87..14c08134e 100644 --- a/tests/AbstractColumnFactoryTest.php +++ b/tests/AbstractColumnFactoryTest.php @@ -7,14 +7,60 @@ use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\TestCase; use Yiisoft\Db\Constant\ColumnType; +use Yiisoft\Db\Driver\Pdo\PdoConnectionInterface; +use Yiisoft\Db\Schema\Column\AbstractArrayColumn; +use Yiisoft\Db\Schema\Column\ArrayLazyColumn; use Yiisoft\Db\Schema\Column\ColumnInterface; +use Yiisoft\Db\Schema\Column\IntegerColumn; +use Yiisoft\Db\Schema\Column\JsonLazyColumn; use Yiisoft\Db\Schema\Column\StringColumn; +use Yiisoft\Db\Schema\Column\StructuredLazyColumn; use Yiisoft\Db\Tests\Provider\ColumnFactoryProvider; -use Yiisoft\Db\Tests\Support\TestTrait; abstract class AbstractColumnFactoryTest extends TestCase { - use TestTrait; + abstract protected function getColumnFactoryClass(): string; + + abstract protected function getConnection(bool $fixture = false): PdoConnectionInterface; + + public function testConstructColumnClassMap(): void + { + $classMap = [ + ColumnType::ARRAY => ArrayLazyColumn::class, + ColumnType::JSON => JsonLazyColumn::class, + ColumnType::STRUCTURED => StructuredLazyColumn::class, + ]; + + $columnFactoryClass = $this->getColumnFactoryClass(); + $columnFactory = new $columnFactoryClass($classMap); + + $this->assertInstanceOf(ArrayLazyColumn::class, $columnFactory->fromType(ColumnType::ARRAY)); + $this->assertInstanceOf(JsonLazyColumn::class, $columnFactory->fromType(ColumnType::JSON)); + $this->assertInstanceOf(StructuredLazyColumn::class, $columnFactory->fromType(ColumnType::STRUCTURED)); + } + + public function testConstructTypeMap(): void + { + $typeMap = [ + 'json' => function (string $dbType, array &$info): string|null { + if (str_ends_with($info['name'], '_ids')) { + $info['column'] = new IntegerColumn(); + return ColumnType::ARRAY; + } + + return null; + }, + ]; + + $columnFactoryClass = $this->getColumnFactoryClass(); + $columnFactory = new $columnFactoryClass(typeMap: $typeMap); + + $column = $columnFactory->fromDbType('json', ['name' => 'user_ids']); + + $this->assertSame(ColumnType::ARRAY, $column->getType()); + $this->assertInstanceOf(AbstractArrayColumn::class, $column); + $this->assertInstanceOf(IntegerColumn::class, $column->getColumn()); + } #[DataProviderExternal(ColumnFactoryProvider::class, 'types')] public function testFromDbType(string $dbType, string $expectedType, string $expectedInstanceOf): void diff --git a/tests/AbstractConnectionTest.php b/tests/AbstractConnectionTest.php index 587bf86dc..0c2e70d2f 100644 --- a/tests/AbstractConnectionTest.php +++ b/tests/AbstractConnectionTest.php @@ -16,6 +16,9 @@ use Yiisoft\Db\Query\BatchQueryResult; use Yiisoft\Db\Query\Query; use Yiisoft\Db\Tests\Support\Assert; +use Yiisoft\Db\Tests\Support\DbHelper; +use Yiisoft\Db\Tests\Support\Stub\ColumnFactory; +use Yiisoft\Db\Tests\Support\Stub\Connection; use Yiisoft\Db\Tests\Support\TestTrait; abstract class AbstractConnectionTest extends TestCase @@ -157,4 +160,24 @@ private function getProfiler(): ProfilerInterface { return $this->createMock(ProfilerInterface::class); } + + public function testGetColumnFactory(): void + { + $db = $this->getConnection(); + + $this->assertInstanceOf(ColumnFactory::class, $db->getColumnFactory()); + + $db->close(); + } + + public function testUserDefinedColumnFactory(): void + { + $columnFactory = new ColumnFactory(); + + $db = new Connection($this->getDriver(), DbHelper::getSchemaCache(), $columnFactory); + + $this->assertSame($columnFactory, $db->getColumnFactory()); + + $db->close(); + } } diff --git a/tests/Db/Connection/ConnectionTest.php b/tests/Db/Connection/ConnectionTest.php index 6a3599292..69749e59c 100644 --- a/tests/Db/Connection/ConnectionTest.php +++ b/tests/Db/Connection/ConnectionTest.php @@ -6,6 +6,9 @@ use Yiisoft\Db\Exception\NotSupportedException; use Yiisoft\Db\Tests\AbstractConnectionTest; +use Yiisoft\Db\Tests\Support\DbHelper; +use Yiisoft\Db\Tests\Support\Stub\ColumnFactory; +use Yiisoft\Db\Tests\Support\Stub\Connection; use Yiisoft\Db\Tests\Support\TestTrait; /** @@ -33,4 +36,13 @@ public function testSerialized(): void parent::testSerialized(); } + + public function testConstructColumnFactory(): void + { + $columnFactory = new ColumnFactory(); + + $db = new Connection($this->getDriver(), DbHelper::getSchemaCache(), $columnFactory); + + $this->assertSame($columnFactory, $db->getColumnFactory()); + } } diff --git a/tests/Db/Schema/Column/ColumnFactoryTest.php b/tests/Db/Schema/Column/ColumnFactoryTest.php index b03aae921..25c0ce86d 100644 --- a/tests/Db/Schema/Column/ColumnFactoryTest.php +++ b/tests/Db/Schema/Column/ColumnFactoryTest.php @@ -5,10 +5,18 @@ namespace Yiisoft\Db\Tests\Db\Schema\Column; use Yiisoft\Db\Tests\AbstractColumnFactoryTest; +use Yiisoft\Db\Tests\Support\Stub\ColumnFactory; +use Yiisoft\Db\Tests\Support\TestTrait; /** * @group db */ final class ColumnFactoryTest extends AbstractColumnFactoryTest { + use TestTrait; + + protected function getColumnFactoryClass(): string + { + return ColumnFactory::class; + } } diff --git a/tests/Support/Stub/Connection.php b/tests/Support/Stub/Connection.php index 5aad02035..a2950abb6 100644 --- a/tests/Support/Stub/Connection.php +++ b/tests/Support/Stub/Connection.php @@ -42,7 +42,7 @@ public function createTransaction(): TransactionInterface public function getColumnFactory(): ColumnFactoryInterface { - return new ColumnFactory(); + return $this->columnFactory ??= new ColumnFactory(); } public function getQueryBuilder(): QueryBuilderInterface diff --git a/tests/Support/TestTrait.php b/tests/Support/TestTrait.php index f16df71d4..9034f6f5c 100644 --- a/tests/Support/TestTrait.php +++ b/tests/Support/TestTrait.php @@ -5,6 +5,7 @@ namespace Yiisoft\Db\Tests\Support; use Yiisoft\Db\Driver\Pdo\PdoConnectionInterface; +use Yiisoft\Db\Driver\Pdo\PdoDriverInterface; use Yiisoft\Db\Tests\Support\Stub\PdoDriver; trait TestTrait @@ -13,7 +14,7 @@ trait TestTrait protected function getConnection(bool $fixture = false): PdoConnectionInterface { - $db = new Stub\Connection(new PdoDriver($this->dsn), DbHelper::getSchemaCache()); + $db = new Stub\Connection($this->getDriver(), DbHelper::getSchemaCache()); if ($fixture) { DbHelper::loadFixture($db, __DIR__ . '/Fixture/db.sql'); @@ -27,6 +28,11 @@ protected static function getDb(): PdoConnectionInterface return new Stub\Connection(new PdoDriver('sqlite::memory:'), DbHelper::getSchemaCache()); } + protected function getDriver(): PdoDriverInterface + { + return new PdoDriver($this->dsn); + } + protected function getDriverName(): string { return 'db';