Skip to content

Commit 4f87e11

Browse files
authored
Use DateTimeColumn (#323)
1 parent faea3d9 commit 4f87e11

15 files changed

+453
-61
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
- Enh #319: Support `boolean` type (@Tigrov)
4545
- Enh #318, #320: Use `DbArrayHelper::arrange()` instead of `DbArrayHelper::index()` method (@Tigrov)
4646
- New #316: Realize `Schema::loadResultColumn()` method (@Tigrov)
47+
- New #323: Use `DateTimeColumn` class for datetime column types (@Tigrov)
4748

4849
## 1.3.0 March 21, 2024
4950

src/Column/ColumnBuilder.php

+30
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,36 @@ public static function boolean(): BooleanColumn
1818
return new BooleanColumn(ColumnType::BOOLEAN);
1919
}
2020

21+
public static function timestamp(int|null $size = 0): DateTimeColumn
22+
{
23+
return new DateTimeColumn(ColumnType::TIMESTAMP, size: $size);
24+
}
25+
26+
public static function datetime(int|null $size = 0): DateTimeColumn
27+
{
28+
return new DateTimeColumn(ColumnType::DATETIME, size: $size);
29+
}
30+
31+
public static function datetimeWithTimezone(int|null $size = 0): DateTimeColumn
32+
{
33+
return new DateTimeColumn(ColumnType::DATETIMETZ, size: $size);
34+
}
35+
36+
public static function time(int|null $size = 0): DateTimeColumn
37+
{
38+
return new DateTimeColumn(ColumnType::TIME, size: $size);
39+
}
40+
41+
public static function timeWithTimezone(int|null $size = 0): DateTimeColumn
42+
{
43+
return new DateTimeColumn(ColumnType::TIMETZ, size: $size);
44+
}
45+
46+
public static function date(): DateTimeColumn
47+
{
48+
return new DateTimeColumn(ColumnType::DATE);
49+
}
50+
2151
public static function json(): JsonColumn
2252
{
2353
return new JsonColumn(ColumnType::JSON);

src/Column/ColumnDefinitionBuilder.php

+4-2
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,12 @@ protected function getDbType(ColumnInterface $column): string
117117
ColumnType::TEXT => 'clob',
118118
ColumnType::BINARY => 'blob',
119119
ColumnType::UUID => 'raw(16)',
120-
ColumnType::DATETIME => 'timestamp',
121120
ColumnType::TIMESTAMP => 'timestamp',
122-
ColumnType::DATE => 'date',
121+
ColumnType::DATETIME => 'timestamp',
122+
ColumnType::DATETIMETZ => 'timestamp' . ($size !== null ? "($size)" : '') . ' with time zone',
123123
ColumnType::TIME => 'interval day(0) to second',
124+
ColumnType::TIMETZ => 'interval day(0) to second',
125+
ColumnType::DATE => 'date',
124126
ColumnType::ARRAY, ColumnType::STRUCTURED, ColumnType::JSON =>
125127
version_compare($this->queryBuilder->getServerInfo()->getVersion(), '21', '>=')
126128
? 'json'

src/Column/ColumnFactory.php

+25-4
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@
55
namespace Yiisoft\Db\Oracle\Column;
66

77
use Yiisoft\Db\Constant\ColumnType;
8+
use Yiisoft\Db\Expression\Expression;
89
use Yiisoft\Db\Schema\Column\AbstractColumnFactory;
910
use Yiisoft\Db\Schema\Column\ColumnInterface;
1011

12+
use function date_create_immutable;
13+
use function preg_match;
1114
use function rtrim;
1215
use function strcasecmp;
1316

1417
final class ColumnFactory extends AbstractColumnFactory
1518
{
19+
private const DATETIME_REGEX = "/^(?:TIMESTAMP|DATE|INTERVAL|to_timestamp(?:_tz)?\(|to_date\(|to_dsinterval\()\s*'(?:\d )?([^']+)/";
20+
1621
/**
1722
* The mapping from physical column types (keys) to abstract column types (values).
1823
*
@@ -39,9 +44,9 @@ final class ColumnFactory extends AbstractColumnFactory
3944
'binary_double' => ColumnType::DOUBLE, // 64 bit
4045
'float' => ColumnType::DOUBLE, // 126 bit
4146
'date' => ColumnType::DATE,
42-
'timestamp' => ColumnType::TIMESTAMP,
43-
'timestamp with time zone' => ColumnType::TIMESTAMP,
44-
'timestamp with local time zone' => ColumnType::TIMESTAMP,
47+
'timestamp' => ColumnType::DATETIME,
48+
'timestamp with time zone' => ColumnType::DATETIMETZ,
49+
'timestamp with local time zone' => ColumnType::DATETIME,
4550
'interval day to second' => ColumnType::STRING,
4651
'interval year to month' => ColumnType::STRING,
4752
'json' => ColumnType::JSON,
@@ -91,13 +96,29 @@ protected function getColumnClass(string $type, array $info = []): string
9196
return match ($type) {
9297
ColumnType::BINARY => BinaryColumn::class,
9398
ColumnType::BOOLEAN => BooleanColumn::class,
99+
ColumnType::DATETIME => DateTimeColumn::class,
100+
ColumnType::DATETIMETZ => DateTimeColumn::class,
101+
ColumnType::TIME => DateTimeColumn::class,
102+
ColumnType::TIMETZ => DateTimeColumn::class,
103+
ColumnType::DATE => DateTimeColumn::class,
94104
ColumnType::JSON => JsonColumn::class,
95105
default => parent::getColumnClass($type, $info),
96106
};
97107
}
98108

99109
protected function normalizeNotNullDefaultValue(string $defaultValue, ColumnInterface $column): mixed
100110
{
101-
return parent::normalizeNotNullDefaultValue(rtrim($defaultValue), $column);
111+
$value = parent::normalizeNotNullDefaultValue(rtrim($defaultValue), $column);
112+
113+
if ($column instanceof DateTimeColumn
114+
&& $value instanceof Expression
115+
&& preg_match(self::DATETIME_REGEX, (string) $value, $matches) === 1
116+
) {
117+
return date_create_immutable($matches[1]) !== false
118+
? $column->phpTypecast($matches[1])
119+
: new Expression($matches[1]);
120+
}
121+
122+
return $value;
102123
}
103124
}

src/Column/DateTimeColumn.php

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\Db\Oracle\Column;
6+
7+
use DateTimeImmutable;
8+
use Yiisoft\Db\Constant\ColumnType;
9+
use Yiisoft\Db\Expression\Expression;
10+
use Yiisoft\Db\Expression\ExpressionInterface;
11+
12+
use function explode;
13+
use function is_string;
14+
use function str_replace;
15+
16+
/**
17+
* Represents the metadata for a datetime column.
18+
*
19+
* > [!WARNING]
20+
* > Oracle DBMS converts `TIMESTAMP WITH LOCAL TIME ZONE` column type values from database session time zone
21+
* > to the database time zone for storage, and back from the database time zone to the session time zone when retrieve
22+
* > the values.
23+
*
24+
* `TIMESTAMP WITH LOCAL TIME ZONE` database type does not store time zone offset and require to convert datetime values
25+
* to the database session time zone before insert and back to the PHP time zone after retrieve the values.
26+
* This will be done in the {@see dbTypecast()} and {@see phpTypecast()} methods and guarantees that the values
27+
* are stored in the database in the correct time zone.
28+
*
29+
* To avoid possible time zone issues with the datetime values conversion, it is recommended to set the PHP and database
30+
* time zones to UTC.
31+
*/
32+
final class DateTimeColumn extends \Yiisoft\Db\Schema\Column\DateTimeColumn
33+
{
34+
public function dbTypecast(mixed $value): string|ExpressionInterface|null
35+
{
36+
$value = parent::dbTypecast($value);
37+
38+
if (!is_string($value)) {
39+
return $value;
40+
}
41+
42+
$value = str_replace(["'", '"', "\000", "\032"], '', $value);
43+
44+
return match ($this->getType()) {
45+
ColumnType::TIMESTAMP, ColumnType::DATETIME, ColumnType::DATETIMETZ => new Expression("TIMESTAMP '$value'"),
46+
ColumnType::TIME, ColumnType::TIMETZ => new Expression(
47+
"INTERVAL '$value' DAY(0) TO SECOND" . (($size = $this->getSize()) !== null ? "($size)" : '')
48+
),
49+
ColumnType::DATE => new Expression("DATE '$value'"),
50+
default => $value,
51+
};
52+
}
53+
54+
public function phpTypecast(mixed $value): DateTimeImmutable|null
55+
{
56+
if (is_string($value) && match ($this->getType()) {
57+
ColumnType::TIME, ColumnType::TIMETZ => true,
58+
default => false,
59+
}) {
60+
$value = explode(' ', $value, 2)[1] ?? $value;
61+
}
62+
63+
return parent::phpTypecast($value);
64+
}
65+
66+
protected function getFormat(): string
67+
{
68+
return $this->format ??= match ($this->getType()) {
69+
ColumnType::TIME, ColumnType::TIMETZ => '0 H:i:s' . $this->getMillisecondsFormat(),
70+
default => parent::getFormat(),
71+
};
72+
}
73+
74+
protected function shouldConvertTimezone(): bool
75+
{
76+
return $this->shouldConvertTimezone ??= !empty($this->dbTimezone) && match ($this->getType()) {
77+
ColumnType::DATETIMETZ,
78+
ColumnType::DATE => false,
79+
default => true,
80+
};
81+
}
82+
}

src/Connection.php

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Yiisoft\Db\Oracle;
66

77
use Throwable;
8+
use Yiisoft\Db\Connection\ServerInfoInterface;
89
use Yiisoft\Db\Driver\Pdo\AbstractPdoConnection;
910
use Yiisoft\Db\Driver\Pdo\PdoCommandInterface;
1011
use Yiisoft\Db\Exception\Exception;
@@ -92,4 +93,9 @@ public function getSchema(): SchemaInterface
9293
{
9394
return $this->schema ??= new Schema($this, $this->schemaCache, strtoupper($this->driver->getUsername()));
9495
}
96+
97+
public function getServerInfo(): ServerInfoInterface
98+
{
99+
return $this->serverInfo ??= new ServerInfo($this);
100+
}
95101
}

src/Driver.php

+15-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,21 @@ final class Driver extends AbstractPdoDriver
1717
public function createConnection(): PDO
1818
{
1919
$this->attributes += [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION];
20-
return parent::createConnection();
20+
21+
$pdo = parent::createConnection();
22+
23+
$pdo->exec(
24+
<<<SQL
25+
ALTER SESSION SET
26+
NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SSXFF'
27+
NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SSXFFTZH:TZM'
28+
NLS_TIME_FORMAT = 'HH24:MI:SSXFF'
29+
NLS_TIME_TZ_FORMAT = 'HH24:MI:SSXFFTZH:TZM'
30+
NLS_DATE_FORMAT = 'YYYY-MM-DD'
31+
SQL
32+
);
33+
34+
return $pdo;
2135
}
2236

2337
public function getDriverName(): string

src/Schema.php

+12-2
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ protected function loadResultColumn(array $metadata): ColumnInterface|null
235235
default => $columnInfo['size'] = $metadata['len'],
236236
};
237237

238+
if ($dbType === 'timestamp with local time zone') {
239+
$columnInfo['dbTimezone'] = $this->db->getServerInfo()->getTimezone();
240+
}
241+
238242
$columnInfo['notNull'] = in_array('not_null', $metadata['flags'], true);
239243

240244
/** @psalm-suppress MixedArgumentTypeCoercion */
@@ -517,7 +521,7 @@ private function loadColumn(array $info): ColumnInterface
517521
default => null,
518522
};
519523

520-
return $this->db->getColumnFactory()->fromDbType($dbType, [
524+
$columnInfo = [
521525
'autoIncrement' => $info['identity_column'] === 'YES',
522526
'check' => $info['check'],
523527
'comment' => $info['column_comment'],
@@ -530,7 +534,13 @@ private function loadColumn(array $info): ColumnInterface
530534
'size' => $info['size'] !== null ? (int) $info['size'] : null,
531535
'table' => $info['table'],
532536
'unique' => $info['constraint_type'] === 'U',
533-
]);
537+
];
538+
539+
if ($dbType === 'timestamp with local time zone') {
540+
$columnInfo['dbTimezone'] = $this->db->getServerInfo()->getTimezone();
541+
}
542+
543+
return $this->db->getColumnFactory()->fromDbType($dbType, $columnInfo);
534544
}
535545

536546
/**

src/ServerInfo.php

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\Db\Oracle;
6+
7+
use Yiisoft\Db\Driver\Pdo\PdoServerInfo;
8+
9+
final class ServerInfo extends PdoServerInfo
10+
{
11+
/** @psalm-suppress PropertyNotSetInConstructor */
12+
private string $timezone;
13+
14+
public function getTimezone(bool $refresh = false): string
15+
{
16+
/** @psalm-suppress TypeDoesNotContainType */
17+
if (!isset($this->timezone) || $refresh) {
18+
/** @var string */
19+
$this->timezone = $this->db->createCommand('SELECT SESSIONTIMEZONE FROM DUAL')->queryScalar();
20+
}
21+
22+
return $this->timezone;
23+
}
24+
}

0 commit comments

Comments
 (0)