diff --git a/src/Schema/AbstractColumnSchema.php b/src/Schema/AbstractColumnSchema.php index 235f2823c..b3a0daa5c 100644 --- a/src/Schema/AbstractColumnSchema.php +++ b/src/Schema/AbstractColumnSchema.php @@ -4,6 +4,10 @@ namespace Yiisoft\Db\Schema; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use Yiisoft\Db\Expression\Expression; use Yiisoft\Db\Expression\ExpressionInterface; use Yiisoft\Db\Helper\DbStringHelper; @@ -45,6 +49,7 @@ abstract class AbstractColumnSchema implements ColumnSchemaInterface private bool $autoIncrement = false; private string|null $comment = null; private bool $computed = false; + private string|null $dateTimeFormat = null; private string|null $dbType = null; private mixed $defaultValue = null; private array|null $enumValues = null; @@ -81,6 +86,11 @@ public function computed(bool $value): void $this->computed = $value; } + public function dateTimeFormat(string|null $value): void + { + $this->dateTimeFormat = $value; + } + public function dbType(string|null $value): void { $this->dbType = $value; @@ -92,6 +102,41 @@ public function dbTypecast(mixed $value): mixed * The default implementation does the same as casting for PHP, but it should be possible to override this with * annotation of an explicit PDO type. */ + + if ($this->dateTimeFormat !== null) { + if (empty($value) || $value instanceof Expression) { + return $value; + } + + if (!$this->hasTimezone() && $this->type !== SchemaInterface::TYPE_DATE) { + // if data type does not have timezone DB stores datetime without timezone + // convert datetime to UTC to avoid timezone issues + if (!$value instanceof DateTimeImmutable) { + // make a copy of $value if change timezone + if ($value instanceof DateTimeInterface) { + $value = DateTimeImmutable::createFromInterface($value); + } elseif (is_string($value)) { + $value = date_create_immutable($value) ?: $value; + } + } + + if ($value instanceof DateTimeImmutable) { // DateTimeInterface does not have the method setTimezone() + $value = $value->setTimezone(new DateTimeZone('UTC')); + // Known possible issues: + // MySQL converts `TIMESTAMP` values from the current time zone to UTC for storage, and back from UTC to the current time zone when retrieve data. + // Oracle `TIMESTAMP WITH LOCAL TIME ZONE` data stored in the database is normalized to the database time zone. And returns it in the users' local session time zone. + // Both of them do not store time zone offset and require to convert DateTime to local DB timezone instead of UTC before insert. + // To solve the issue it requires to set local DB timezone to UTC if the types are in use + } + } + + if ($value instanceof DateTimeInterface) { + return $value->format($this->dateTimeFormat); + } + + return (string) $value; + } + return $this->typecast($value); } @@ -115,6 +160,11 @@ public function getComment(): string|null return $this->comment; } + public function getDateTimeFormat(): string|null + { + return $this->dateTimeFormat; + } + public function getDbType(): string|null { return $this->dbType; @@ -165,6 +215,11 @@ public function getType(): string return $this->type; } + public function hasTimezone(): bool + { + return false; + } + public function isAllowNull(): bool { return $this->allowNull; @@ -195,8 +250,23 @@ public function phpType(string|null $value): void $this->phpType = $value; } + /** + * @throws \Exception + */ public function phpTypecast(mixed $value): mixed { + if (is_string($value) && $this->dateTimeFormat !== null) { + if (!$this->hasTimezone()) { + // if data type does not have timezone datetime was converted to UTC before insert + $datetime = new DateTimeImmutable($value, new DateTimeZone('UTC')); + + // convert datetime to PHP timezone + return $datetime->setTimezone(new DateTimeZone(date_default_timezone_get())); + } + + return new DateTimeImmutable($value); + } + return $this->typecast($value); } diff --git a/src/Schema/AbstractSchema.php b/src/Schema/AbstractSchema.php index cd1c89cb0..a69730c5e 100644 --- a/src/Schema/AbstractSchema.php +++ b/src/Schema/AbstractSchema.php @@ -418,6 +418,11 @@ protected function getColumnPhpType(ColumnSchemaInterface $column): string SchemaInterface::TYPE_DOUBLE => SchemaInterface::PHP_TYPE_DOUBLE, SchemaInterface::TYPE_BINARY => SchemaInterface::PHP_TYPE_RESOURCE, SchemaInterface::TYPE_JSON => SchemaInterface::PHP_TYPE_ARRAY, + SchemaInterface::TYPE_DATETIME => SchemaInterface::PHP_TYPE_DATE_TIME, + SchemaInterface::TYPE_TIMESTAMP => SchemaInterface::PHP_TYPE_DATE_TIME, + SchemaInterface::TYPE_DATE => SchemaInterface::PHP_TYPE_DATE_TIME, + SchemaInterface::TYPE_TIME => SchemaInterface::PHP_TYPE_DATE_TIME, + default => SchemaInterface::PHP_TYPE_STRING, }; } @@ -648,4 +653,30 @@ public function getViewNames(string $schema = '', bool $refresh = false): array return (array) $this->viewNames[$schema]; } + + protected function getDateTimeFormat(ColumnSchemaInterface $column): string|null + { + return match ($column->getType()) { + self::TYPE_TIMESTAMP, + self::TYPE_DATETIME => 'Y-m-d H:i:s' + . $this->getMillisecondsFormat($column) + . ($column->hasTimezone() ? 'P' : ''), + self::TYPE_DATE => 'Y-m-d', + self::TYPE_TIME => 'H:i:s' + . $this->getMillisecondsFormat($column) + . ($column->hasTimezone() ? 'P' : ''), + default => null, + }; + } + + protected function getMillisecondsFormat(ColumnSchemaInterface $column): string + { + $precision = $column->getPrecision(); + + return match (true) { + $precision > 3 => '.u', + $precision > 0 => '.v', + default => '', + }; + } } diff --git a/src/Schema/ColumnSchemaInterface.php b/src/Schema/ColumnSchemaInterface.php index 65778a652..b56d6d5ff 100644 --- a/src/Schema/ColumnSchemaInterface.php +++ b/src/Schema/ColumnSchemaInterface.php @@ -64,6 +64,13 @@ public function comment(string|null $value): void; */ public function computed(bool $value): void; + /** + * The datetime format to convert value from `DateTimeInterface` to a database representation. + * + * It defines from table schema. + */ + public function dateTimeFormat(string|null $value): void; + /** * The database data-type of column. * @@ -134,6 +141,13 @@ public function extra(string|null $value): void; */ public function getComment(): string|null; + /** + * @return string|null The datetime format. + * + * @see dateTimeFormat() + */ + public function getDateTimeFormat(): string|null; + /** * @return string|null The database type of the column. * Null means the column has no type in the database. @@ -206,6 +220,11 @@ public function getSize(): int|null; */ public function getType(): string; + /** + * @return bool True if the datetime type has a timezone, false otherwise. + */ + public function hasTimezone(): bool; + /** * Whether this column is nullable. * diff --git a/src/Schema/SchemaInterface.php b/src/Schema/SchemaInterface.php index 684a11f6e..02ded88f9 100644 --- a/src/Schema/SchemaInterface.php +++ b/src/Schema/SchemaInterface.php @@ -4,6 +4,7 @@ namespace Yiisoft\Db\Schema; +use DateTimeInterface; use Throwable; use Yiisoft\Db\Command\DataType; use Yiisoft\Db\Constraint\ConstraintSchemaInterface; @@ -247,6 +248,10 @@ interface SchemaInterface extends ConstraintSchemaInterface * Define the php type as `array` for cast to php value. */ public const PHP_TYPE_ARRAY = 'array'; + /** + * Define the php type as `DateTimeInterface` for cast to php value. + */ + public const PHP_TYPE_DATE_TIME = DateTimeInterface::class; /** * Define the php type as `null` for cast to php value. */ diff --git a/tests/Common/CommonSchemaTest.php b/tests/Common/CommonSchemaTest.php index c91b4e9ff..6f3f9cee4 100644 --- a/tests/Common/CommonSchemaTest.php +++ b/tests/Common/CommonSchemaTest.php @@ -885,9 +885,9 @@ protected function columnSchema(array $columns, string $table): void $column->getDefaultValue(), "defaultValue of column $name is expected to be an object but it is not." ); - $this->assertSame( - (string) $expected['defaultValue'], - (string) $column->getDefaultValue(), + $this->assertEquals( + $expected['defaultValue'], + $column->getDefaultValue(), "defaultValue of column $name does not match." ); } else { @@ -907,6 +907,14 @@ protected function columnSchema(array $columns, string $table): void "dimension of column $name does not match" ); } + + if (isset($expected['dateTimeFormat'])) { + $this->assertSame( + $expected['dateTimeFormat'], + $column->getDateTimeFormat(), + "dateTimeFormat of column $name does not match" + ); + } } $db->close();