diff --git a/src/Database/Connection.php b/src/Database/Connection.php index bac2c2677..8e30358fa 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, ResultSet): array */ private $rowNormalizer = [Helpers::class, 'normalizeRow']; @@ -43,13 +43,12 @@ 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']); - $this->driver = (new Factory)->createDriverFromDsn($dsn, $user, $password, $options); + $factory = new Factory; + $this->typeConverter = $factory->createTypeConverter($options); + $this->driver = $factory->createDriverFromDsn($dsn, $user, $password, $options); if (!$lazy) { $this->connect(); } @@ -134,6 +133,12 @@ public function getReflection(): Reflection } + public function getTypeConverter(): TypeConverter + { + return $this->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..68e4d2688 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['nativeType']); } diff --git a/src/Database/Drivers/Engines/MySQLEngine.php b/src/Database/Drivers/Engines/MySQLEngine.php index ab2454be9..a40db82c2 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['precision'] === 0 + ? $converter->toInt(...) + : $converter->toFloat(...), // precision in PDOStatement::getColumnMeta() means scale + 'TINY' => $meta['length'] === 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['nativeType']), + }; } diff --git a/src/Database/Drivers/Engines/ODBCEngine.php b/src/Database/Drivers/Engines/ODBCEngine.php index a868747c3..e37588f0c 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['nativeType']); } diff --git a/src/Database/Drivers/Engines/OracleEngine.php b/src/Database/Drivers/Engines/OracleEngine.php index 776374f92..e17e20463 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['nativeType']); } diff --git a/src/Database/Drivers/Engines/PostgreSQLEngine.php b/src/Database/Drivers/Engines/PostgreSQLEngine.php index fe73f5aad..8c06408e8 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['nativeType']); } diff --git a/src/Database/Drivers/Engines/SQLServerEngine.php b/src/Database/Drivers/Engines/SQLServerEngine.php index 5b1c81b00..baaacffd3 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): float => (float) (is_string($value) && str_starts_with($value, '.') ? '0' . $value : $value), + default => $converter->resolve($meta['nativeType']), + }; } diff --git a/src/Database/Drivers/Engines/SQLiteEngine.php b/src/Database/Drivers/Engines/SQLiteEngine.php index ee4a4f5d5..a3f2dfd4b 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['nativeType']); } 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 46f830ce9..1a268dfff 100644 --- a/src/Database/Factory.php +++ b/src/Database/Factory.php @@ -47,4 +47,17 @@ public function createDriverFromDsn( return new $class(['dsn' => $dsn, 'username' => $username, 'password' => $password, 'options' => $options]); } + + + public function createTypeConverter(array &$options): TypeConverter + { + $converter = new TypeConverter; + foreach (['convertBoolean', 'newDateTime'] as $opt) { + if (isset($options[$opt])) { + $converter->$opt = (bool) $options[$opt]; + unset($options[$opt]); + } + } + return $converter; + } } diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php index 7455aaab3..800f617a4 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/ResultSet.php b/src/Database/ResultSet.php index babf1307c..c84525dc2 100644 --- a/src/Database/ResultSet.php +++ b/src/Database/ResultSet.php @@ -27,7 +27,7 @@ class ResultSet 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; @@ -245,4 +238,29 @@ public function fetchAssoc(string $path): array { return Nette\Utils\Arrays::associate($this->fetchAll(), $path); } + + + public function resolveColumnConverters(): array + { + if (isset($this->converters)) { + return $this->converters; + } + + $res = []; + $engine = $this->connection->getDatabaseEngine(); + $converter = $this->connection->getTypeConverter(); + $metaTypeKey = $this->connection->getConnectionDriver()->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'], + 'precision' => $meta['precision'], + ], $converter); + } + } + return $this->converters = $res; + } } diff --git a/src/Database/TypeConverter.php b/src/Database/TypeConverter.php index f915c48dc..4da681216 100644 --- a/src/Database/TypeConverter.php +++ b/src/Database/TypeConverter.php @@ -12,35 +12,110 @@ 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(string $nativeType): ?\Closure + { + return match ($this->detectType($nativeType)) { + self::Integer => $this->toInt(...), + self::Decimal => $this->toDecimal(...), + self::Float => $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 toDecimal(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; } }