From eec3b29e1d20c51645bd218c72b115038ec5bc72 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 29 Aug 2024 04:16:22 +0200 Subject: [PATCH] Result::getColumnTypes() redesigned, uses TypeConverter --- src/Database/Connection.php | 14 ++- src/Database/Drivers/Engine.php | 7 +- src/Database/Drivers/Engines/MSSQLEngine.php | 5 +- src/Database/Drivers/Engines/MySQLEngine.php | 33 +++--- src/Database/Drivers/Engines/ODBCEngine.php | 5 +- src/Database/Drivers/Engines/OracleEngine.php | 5 +- .../Drivers/Engines/PostgreSQLEngine.php | 10 +- .../Drivers/Engines/SQLServerEngine.php | 23 ++-- src/Database/Drivers/Engines/SQLiteEngine.php | 20 +--- src/Database/Drivers/PDO/Connection.php | 3 +- src/Database/Drivers/PDO/MySQL/Driver.php | 7 +- src/Database/Drivers/PDO/SQLSrv/Driver.php | 4 +- src/Database/Drivers/PDO/SQLite/Driver.php | 4 +- src/Database/Factory.php | 14 +++ src/Database/Helpers.php | 55 +-------- src/Database/Result.php | 34 ++++-- src/Database/TypeConverter.php | 105 +++++++++++++++--- 17 files changed, 191 insertions(+), 157 deletions(-) diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 04d42c6d6..2a53bbe94 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -11,7 +11,6 @@ use JetBrains\PhpStorm\Language; use Nette\Utils\Arrays; -use Nette\Utils\DateTime; use PDOException; @@ -29,6 +28,7 @@ class Connection private ?Drivers\Connection $connection = null; private Drivers\Engine $engine; private SqlPreprocessor $preprocessor; + private TypeConverter $typeConverter; /** @var callable(array, Result): array */ private $rowNormalizer = [Helpers::class, 'normalizeRow']; @@ -43,12 +43,10 @@ public function __construct( ?string $password = null, array $options = [], ) { - if (($options['newDateTime'] ?? null) === false) { - $this->rowNormalizer = fn($row, $resultSet) => Helpers::normalizeRow($row, $resultSet, DateTime::class); - } $lazy = $options['lazy'] ?? false; - unset($options['newDateTime'], $options['lazy']); + unset($options['lazy']); + Factory::configure($this, $options); $this->driver = Factory::createDriverFromDsn($dsn, $user, $password, $options); if (!$lazy) { $this->connect(); @@ -132,6 +130,12 @@ public function getReflection(): Reflection } + public function getTypeConverter(): TypeConverter + { + return $this->typeConverter ??= new TypeConverter; + } + + public function setRowNormalizer(?callable $normalizer): static { $this->rowNormalizer = $normalizer; diff --git a/src/Database/Drivers/Engine.php b/src/Database/Drivers/Engine.php index 6df0d9d3c..6ca92c3b9 100644 --- a/src/Database/Drivers/Engine.php +++ b/src/Database/Drivers/Engine.php @@ -10,6 +10,7 @@ namespace Nette\Database\Drivers; use Nette\Database; +use Nette\Database\TypeConverter; /** @@ -69,11 +70,7 @@ function getIndexes(string $table): array; /** @return list */ function getForeignKeys(string $table): array; - /** - * Returns associative array of detected types (IStructure::FIELD_*) in result set. - * @return array - */ - function getColumnTypes(\PDOStatement $statement): array; + function resolveColumnConverter(array $meta, TypeConverter $converter): ?\Closure; /** * Cheks if driver supports specific property diff --git a/src/Database/Drivers/Engines/MSSQLEngine.php b/src/Database/Drivers/Engines/MSSQLEngine.php index 983f3ddbc..37ffd8646 100644 --- a/src/Database/Drivers/Engines/MSSQLEngine.php +++ b/src/Database/Drivers/Engines/MSSQLEngine.php @@ -12,6 +12,7 @@ use Nette; use Nette\Database\Drivers\Connection; use Nette\Database\Drivers\Engine; +use Nette\Database\TypeConverter; /** @@ -213,9 +214,9 @@ public function getForeignKeys(string $table): array } - public function getColumnTypes(\PDOStatement $statement): array + public function resolveColumnConverter(array $meta, TypeConverter $converter): ?\Closure { - return Nette\Database\Helpers::detectTypes($statement); + return $converter->resolve($meta); } diff --git a/src/Database/Drivers/Engines/MySQLEngine.php b/src/Database/Drivers/Engines/MySQLEngine.php index d82b2fbc6..d3b866524 100644 --- a/src/Database/Drivers/Engines/MySQLEngine.php +++ b/src/Database/Drivers/Engines/MySQLEngine.php @@ -20,9 +20,6 @@ */ class MySQLEngine implements Engine { - public bool $convertBoolean = true; - - public function __construct( private readonly Connection $connection, ) { @@ -176,23 +173,21 @@ public function getForeignKeys(string $table): array } - public function getColumnTypes(\PDOStatement $statement): array + public function resolveColumnConverter(array $meta, TypeConverter $converter): ?\Closure { - $types = []; - $count = $statement->columnCount(); - for ($col = 0; $col < $count; $col++) { - $meta = $statement->getColumnMeta($col); - if (isset($meta['native_type'])) { - $types[$meta['name']] = match (true) { - $meta['native_type'] === 'NEWDECIMAL' && $meta['precision'] === 0 => Nette\Database\IStructure::FIELD_INTEGER, - $meta['native_type'] === 'TINY' && $meta['len'] === 1 && $this->convertBoolean => Nette\Database\IStructure::FIELD_BOOL, - $meta['native_type'] === 'TIME' => Nette\Database\IStructure::FIELD_TIME_INTERVAL, - default => TypeConverter::detectType($meta['native_type']), - }; - } - } - - return $types; + return match ($meta['nativeType']) { + 'NEWDECIMAL' => $meta['scale'] === 0 + ? $converter->toInt(...) + : $converter->toFloat(...), + 'TINY' => $meta['len'] === 1 && $converter->convertBoolean + ? $converter->toBool(...) + : $converter->toInt(...), + 'TIME' => $converter->toInterval(...), + 'DATE', 'DATETIME', 'TIMESTAMP' => fn($value): ?\DateTimeInterface => str_starts_with($value, '0000-00') + ? null + : $converter->toDateTime($value), + default => $converter->resolve($meta), + }; } diff --git a/src/Database/Drivers/Engines/ODBCEngine.php b/src/Database/Drivers/Engines/ODBCEngine.php index a868747c3..ebff18ced 100644 --- a/src/Database/Drivers/Engines/ODBCEngine.php +++ b/src/Database/Drivers/Engines/ODBCEngine.php @@ -11,6 +11,7 @@ use Nette; use Nette\Database\Drivers\Engine; +use Nette\Database\TypeConverter; /** @@ -96,9 +97,9 @@ public function getForeignKeys(string $table): array } - public function getColumnTypes(\PDOStatement $statement): array + public function resolveColumnConverter(array $meta, TypeConverter $converter): ?\Closure { - return []; + return $converter->resolve($meta); } diff --git a/src/Database/Drivers/Engines/OracleEngine.php b/src/Database/Drivers/Engines/OracleEngine.php index 776374f92..f6bd450b4 100644 --- a/src/Database/Drivers/Engines/OracleEngine.php +++ b/src/Database/Drivers/Engines/OracleEngine.php @@ -12,6 +12,7 @@ use Nette; use Nette\Database\Drivers\Connection; use Nette\Database\Drivers\Engine; +use Nette\Database\TypeConverter; /** @@ -130,9 +131,9 @@ public function getForeignKeys(string $table): array } - public function getColumnTypes(\PDOStatement $statement): array + public function resolveColumnConverter(array $meta, TypeConverter $converter): ?\Closure { - return []; + return $converter->resolve($meta); } diff --git a/src/Database/Drivers/Engines/PostgreSQLEngine.php b/src/Database/Drivers/Engines/PostgreSQLEngine.php index fe73f5aad..35ad3566b 100644 --- a/src/Database/Drivers/Engines/PostgreSQLEngine.php +++ b/src/Database/Drivers/Engines/PostgreSQLEngine.php @@ -12,6 +12,7 @@ use Nette; use Nette\Database\Drivers\Connection; use Nette\Database\Drivers\Engine; +use Nette\Database\TypeConverter; /** @@ -238,12 +239,11 @@ public function getForeignKeys(string $table): array } - public function getColumnTypes(\PDOStatement $statement): array + public function resolveColumnConverter(array $meta, TypeConverter $converter): ?\Closure { - static $cache; - $item = &$cache[$statement->queryString]; - $item ??= Nette\Database\Helpers::detectTypes($statement); - return $item; + return $meta['nativeType'] === 'bool' + ? fn($value): bool => ($value && $value !== 'f' && $value !== 'F') + : $converter->resolve($meta); } diff --git a/src/Database/Drivers/Engines/SQLServerEngine.php b/src/Database/Drivers/Engines/SQLServerEngine.php index 5b1c81b00..f1e4f9e83 100644 --- a/src/Database/Drivers/Engines/SQLServerEngine.php +++ b/src/Database/Drivers/Engines/SQLServerEngine.php @@ -218,23 +218,14 @@ public function getForeignKeys(string $table): array } - public function getColumnTypes(\PDOStatement $statement): array + public function resolveColumnConverter(array $meta, TypeConverter $converter): ?\Closure { - $types = []; - $count = $statement->columnCount(); - for ($col = 0; $col < $count; $col++) { - $meta = $statement->getColumnMeta($col); - if ( - isset($meta['sqlsrv:decl_type']) - && $meta['sqlsrv:decl_type'] !== 'timestamp' - ) { // timestamp does not mean time in sqlsrv - $types[$meta['name']] = TypeConverter::detectType($meta['sqlsrv:decl_type']); - } elseif (isset($meta['native_type'])) { - $types[$meta['name']] = TypeConverter::detectType($meta['native_type']); - } - } - - return $types; + return match ($meta['nativeType']) { + 'timestamp' => null, // timestamp does not mean time in sqlsrv + 'decimal', 'numeric', + 'double', 'double precision', 'float', 'real', 'money', 'smallmoney' => fn($value) => ($fn = $converter->resolve($meta)) ? $fn(is_string($value) && str_starts_with($value, '.') ? '0' . $value : $value) : $value, + default => $converter->resolve($meta), + }; } diff --git a/src/Database/Drivers/Engines/SQLiteEngine.php b/src/Database/Drivers/Engines/SQLiteEngine.php index 6ba8b8cb6..2764641a9 100644 --- a/src/Database/Drivers/Engines/SQLiteEngine.php +++ b/src/Database/Drivers/Engines/SQLiteEngine.php @@ -10,6 +10,7 @@ namespace Nette\Database\Drivers\Engines; use Nette; +use Nette\Database\DateTime; use Nette\Database\Drivers\Connection; use Nette\Database\Drivers\Engine; use Nette\Database\TypeConverter; @@ -228,22 +229,11 @@ public function getForeignKeys(string $table): array } - public function getColumnTypes(\PDOStatement $statement): array + public function resolveColumnConverter(array $meta, TypeConverter $converter): ?\Closure { - $types = []; - $count = $statement->columnCount(); - for ($col = 0; $col < $count; $col++) { - $meta = $statement->getColumnMeta($col); - if (isset($meta['sqlite:decl_type'])) { - $types[$meta['name']] = $this->formatDateTime === 'U' && in_array($meta['sqlite:decl_type'], ['DATE', 'DATETIME'], strict: true) - ? Nette\Database\IStructure::FIELD_UNIX_TIMESTAMP - : TypeConverter::detectType($meta['sqlite:decl_type']); - } elseif (isset($meta['native_type'])) { - $types[$meta['name']] = TypeConverter::detectType($meta['native_type']); - } - } - - return $types; + return in_array($meta['nativeType'], ['DATE', 'DATETIME'], true) + ? (fn($value): \DateTimeInterface => is_int($value) ? (new DateTime)->setTimestamp($value) : new DateTime($value)) + : $converter->resolve($meta); } diff --git a/src/Database/Drivers/PDO/Connection.php b/src/Database/Drivers/PDO/Connection.php index a39b1aedd..d746139c3 100644 --- a/src/Database/Drivers/PDO/Connection.php +++ b/src/Database/Drivers/PDO/Connection.php @@ -15,7 +15,8 @@ class Connection implements Drivers\Connection { - protected readonly PDO $pdo; + public readonly PDO $pdo; + public string $metaTypeKey = 'native_type'; public function __construct( diff --git a/src/Database/Drivers/PDO/MySQL/Driver.php b/src/Database/Drivers/PDO/MySQL/Driver.php index d0154da18..9174f77df 100644 --- a/src/Database/Drivers/PDO/MySQL/Driver.php +++ b/src/Database/Drivers/PDO/MySQL/Driver.php @@ -49,11 +49,6 @@ public function connect(): Drivers\Connection public function createDatabaseEngine(Drivers\Connection $connection): Drivers\Engine { - $engine = new (self::EngineClass)($connection); - $options = $this->params['options']; - if (isset($options['convertBoolean'])) { - $engine->convertBoolean = (bool) $options['convertBoolean']; - } - return $engine; + return new (self::EngineClass)($connection); } } diff --git a/src/Database/Drivers/PDO/SQLSrv/Driver.php b/src/Database/Drivers/PDO/SQLSrv/Driver.php index a57006615..8025fae1e 100644 --- a/src/Database/Drivers/PDO/SQLSrv/Driver.php +++ b/src/Database/Drivers/PDO/SQLSrv/Driver.php @@ -29,7 +29,9 @@ public function __construct( public function connect(): Drivers\Connection { - return new Drivers\PDO\Connection(...$this->params); + $connection = new Drivers\PDO\Connection(...$this->params); + $connection->metaTypeKey = 'sqlsrv:decl_type'; + return $connection; } diff --git a/src/Database/Drivers/PDO/SQLite/Driver.php b/src/Database/Drivers/PDO/SQLite/Driver.php index 7aa128d14..645f09d91 100644 --- a/src/Database/Drivers/PDO/SQLite/Driver.php +++ b/src/Database/Drivers/PDO/SQLite/Driver.php @@ -31,7 +31,9 @@ public function __construct( public function connect(): Drivers\Connection { - return new Drivers\PDO\Connection(...$this->params); + $connection = new Drivers\PDO\Connection(...$this->params); + $connection->metaTypeKey = 'sqlite:decl_type'; + return $connection; } diff --git a/src/Database/Factory.php b/src/Database/Factory.php index d5595cf39..46f9d20d7 100644 --- a/src/Database/Factory.php +++ b/src/Database/Factory.php @@ -25,6 +25,7 @@ final class Factory 'pdo-sqlite' => Drivers\PDO\SQLite\Driver::class, 'pdo-sqlsrv' => Drivers\PDO\SQLSrv\Driver::class, ]; + private const TypeConverterOptions = ['convertBoolean', 'newDateTime']; /** @internal */ @@ -51,4 +52,17 @@ public static function createDriverFromDsn( return new $class(['dsn' => $dsn, 'username' => $username, 'password' => $password, 'options' => $options]); } + + + /** @internal */ + public static function configure(Connection $connection, array $options): void + { + $converter = $connection->getTypeConverter(); + foreach (self::TypeConverterOptions as $opt) { + if (isset($options[$opt])) { + $converter->$opt = (bool) $options[$opt]; + unset($options[$opt]); + } + } + } } diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php index 22bd1d59c..fa962eb0c 100644 --- a/src/Database/Helpers.php +++ b/src/Database/Helpers.php @@ -151,24 +151,6 @@ public static function dumpSql(string $sql, ?array $params = null, ?Connection $ } - /** - * Common column type detection. - */ - public static function detectTypes(\PDOStatement $statement): array - { - $types = []; - $count = $statement->columnCount(); // driver must be meta-aware, see PHP bugs #53782, #54695 - for ($col = 0; $col < $count; $col++) { - $meta = $statement->getColumnMeta($col); - if (isset($meta['native_type'])) { - $types[$meta['name']] = TypeConverter::detectType($meta['native_type']); - } - } - - return $types; - } - - /** @internal */ public static function normalizeRow( array $row, @@ -176,41 +158,12 @@ public static function normalizeRow( $dateTimeClass = DateTime::class, ): array { - foreach ($resultSet->getColumnTypes() as $key => $type) { + foreach ($resultSet->resolveColumnConverters() as $key => $converter) { $value = $row[$key]; - if ($value === null || $value === false || $type === IStructure::FIELD_TEXT) { - // do nothing - } elseif ($type === IStructure::FIELD_INTEGER) { - $row[$key] = is_float($tmp = $value * 1) ? $value : $tmp; - - } elseif ($type === IStructure::FIELD_FLOAT || $type === IStructure::FIELD_DECIMAL) { - if (is_string($value) && str_starts_with($value, '.')) { - $value = '0' . $value; - } - $row[$key] = (float) $value; - - } elseif ($type === IStructure::FIELD_BOOL) { - $row[$key] = $value && $value !== 'f' && $value !== 'F'; - - } elseif ($type === IStructure::FIELD_DATETIME || $type === IStructure::FIELD_DATE) { - $row[$key] = str_starts_with($value, '0000-00') - ? null - : new $dateTimeClass($value); - - } elseif ($type === IStructure::FIELD_TIME) { - $row[$key] = (new $dateTimeClass($value))->setDate(1, 1, 1); - - } elseif ($type === IStructure::FIELD_TIME_INTERVAL) { - preg_match('#^(-?)(\d+)\D(\d+)\D(\d+)(\.\d+)?$#D', $value, $m); - $row[$key] = new \DateInterval("PT$m[2]H$m[3]M$m[4]S"); - $row[$key]->f = isset($m[5]) ? (float) $m[5] : 0.0; - $row[$key]->invert = (int) (bool) $m[1]; - - } elseif ($type === IStructure::FIELD_UNIX_TIMESTAMP) { - $row[$key] = (new $dateTimeClass)->setTimestamp($value); - } + $row[$key] = isset($value, $converter) + ? $converter($value) + : $value; } - return $row; } diff --git a/src/Database/Result.php b/src/Database/Result.php index 8cd0fb310..637bbec0c 100644 --- a/src/Database/Result.php +++ b/src/Database/Result.php @@ -28,7 +28,7 @@ class Result implements \Iterator /** @var Row[] */ private array $rows; private float $time; - private array $types; + private array $converters; public function __construct( @@ -98,13 +98,6 @@ public function getRowCount(): ?int } - public function getColumnTypes(): array - { - $this->types ??= $this->connection->getDatabaseEngine()->getColumnTypes($this->pdoStatement); - return $this->types; - } - - public function getTime(): float { return $this->time; @@ -259,6 +252,31 @@ public function fetchAll(): array $this->rows ??= iterator_to_array($this); return $this->rows; } + + + public function resolveColumnConverters(): array + { + if (isset($this->converters)) { + return $this->converters; + } + + $res = []; + $engine = $this->connection->getDatabaseEngine(); + $converter = $this->connection->getTypeConverter(); + $metaTypeKey = $this->connection->getConnection()->metaTypeKey; + $count = $this->pdoStatement->columnCount(); + for ($i = 0; $i < $count; $i++) { + $meta = $this->pdoStatement->getColumnMeta($i); + if (isset($meta[$metaTypeKey])) { + $res[$meta['name']] = $engine->resolveColumnConverter([ + 'nativeType' => $meta[$metaTypeKey], + 'length' => $meta['len'], + 'scale' => $meta['precision'], + ], $converter); + } + } + return $this->converters = $res; + } } diff --git a/src/Database/TypeConverter.php b/src/Database/TypeConverter.php index f915c48dc..e2a89c883 100644 --- a/src/Database/TypeConverter.php +++ b/src/Database/TypeConverter.php @@ -12,35 +12,104 @@ final class TypeConverter { - public static array $typePatterns = [ - '^_' => IStructure::FIELD_TEXT, // PostgreSQL arrays - '(TINY|SMALL|SHORT|MEDIUM|BIG|LONG)(INT)?|INT(EGER|\d+| IDENTITY| UNSIGNED)?|(SMALL|BIG|)SERIAL\d*|COUNTER|YEAR|BYTE|LONGLONG|UNSIGNED BIG INT' => IStructure::FIELD_INTEGER, - '(NEW)?DEC(IMAL)?(\(.*)?|NUMERIC|(SMALL)?MONEY|CURRENCY|NUMBER' => IStructure::FIELD_DECIMAL, - 'REAL|DOUBLE( PRECISION)?|FLOAT\d*' => IStructure::FIELD_FLOAT, - 'BOOL(EAN)?' => IStructure::FIELD_BOOL, - 'TIME' => IStructure::FIELD_TIME, - 'DATE' => IStructure::FIELD_DATE, - '(SMALL)?DATETIME(OFFSET)?\d*|TIME(STAMP.*)?' => IStructure::FIELD_DATETIME, - 'BYTEA|(TINY|MEDIUM|LONG|)BLOB|(LONG )?(VAR)?BINARY|IMAGE' => IStructure::FIELD_BINARY, + private const + Binary = 'binary', + Boolean = 'boolean', + Date = 'date', + DateTime = 'datetime', + Decimal = 'decimal', + Float = 'float', + Integer = 'integer', + Interval = 'interval', + Text = 'text', + Time = 'time'; + + private const Patterns = [ + '^_' => self::Text, // PostgreSQL arrays + '(TINY|SMALL|SHORT|MEDIUM|BIG|LONG)(INT)?|INT(EGER|\d+| IDENTITY| UNSIGNED)?|(SMALL|BIG|)SERIAL\d*|COUNTER|YEAR|BYTE|LONGLONG|UNSIGNED BIG INT' => self::Integer, + '(NEW)?DEC(IMAL)?(\(.*)?|NUMERIC|(SMALL)?MONEY|CURRENCY|NUMBER' => self::Decimal, + 'REAL|DOUBLE( PRECISION)?|FLOAT\d*' => self::Float, + 'BOOL(EAN)?' => self::Boolean, + 'TIME' => self::Time, + 'DATE' => self::Date, + '(SMALL)?DATETIME(OFFSET)?\d*|TIME(STAMP.*)?' => self::DateTime, + 'BYTEA|(TINY|MEDIUM|LONG|)BLOB|(LONG )?(VAR)?BINARY|IMAGE' => self::Binary, ]; + public bool $convertBoolean = true; + public bool $newDateTime = true; + /** * Heuristic column type detection. - * @return Type::* */ - public static function detectType(string $type): string + private function detectType(string $nativeType): string { static $cache; - if (!isset($cache[$type])) { - $cache[$type] = IStructure::FIELD_TEXT; - foreach (self::$typePatterns as $s => $val) { - if (preg_match("#^($s)$#i", $type)) { - return $cache[$type] = $val; + if (!isset($cache[$nativeType])) { + $cache[$nativeType] = self::Text; + foreach (self::Patterns as $s => $val) { + if (preg_match("#^($s)$#i", $nativeType)) { + return $cache[$nativeType] = $val; } } } - return $cache[$type]; + return $cache[$nativeType]; + } + + + public function resolve(array $meta): ?\Closure + { + return match ($this->detectType($meta['nativeType'])) { + self::Integer => $this->toInt(...), + self::Float, + self::Decimal => $this->toFloat(...), + self::Boolean => $this->convertBoolean ? $this->toBool(...) : null, + self::DateTime, self::Date => $this->toDateTime(...), + self::Time => $this->toTime(...), + self::Interval => self::toInterval(...), + default => null, + }; + } + + + public function toInt(int|string $value): int|float + { + return is_float($tmp = $value * 1) ? $value : $tmp; + } + + + public function toFloat(float|string $value): float + { + return (float) $value; + } + + + public function toBool(bool|int|string $value): bool + { + return (bool) $value; + } + + + public function toDateTime(string $value): \DateTimeInterface + { + return $this->newDateTime ? new DateTime($value) : new \Nette\Utils\DateTime($value); + } + + + public function toTime(string $value): \DateTimeInterface + { + return $this->toDateTime($value)->setDate(1, 1, 1); + } + + + public function toInterval(string $value): \DateInterval + { + preg_match('#^(-?)(\d+)\D(\d+)\D(\d+)(\.\d+)?$#D', $value, $m); + $interval = new \DateInterval("PT$m[2]H$m[3]M$m[4]S"); + $interval->f = isset($m[5]) ? (float) $m[5] : 0.0; + $interval->invert = (int) (bool) $m[1]; + return $interval; } }