返回

Doctrine 3: CURRENT_TIMESTAMP 失效? 原理解析及解决

php

Doctrine 3 “default” => 'CURRENT_TIMESTAMP' 失效问题剖析

在使用 Doctrine ORM 处理数据库实体时,为时间戳类型的字段设置默认值是一个常见需求。期望的效果是,当创建新实体而没有明确设置时间字段时,数据库会自动使用当前时间戳填充。但是,直接在 Doctrine 的 @Column 注解中设置 'default' => 'CURRENT_TIMESTAMP' 时,可能无法达到预期效果,导致 NOT NULL 字段插入时报空值错误,这给开发者带来困惑。

问题根源:Doctrine 字段默认值处理机制

当在 Doctrine 的字段映射中使用 default 选项指定为 CURRENT_TIMESTAMP时,这个设置主要体现在数据库表结构定义中(例如,通过 orm:schema-tool:create 生成的SQL)。这个定义会告知数据库,当插入数据没有指定该字段值时,采用 CURRENT_TIMESTAMP 填充。但值得关注的点是 Doctrine 实体层,默认值的设置并没有反映到 PHP 对象本身。

Doctrine 的字段映射中 updatable: false 参数虽然保证了此字段值在更新时不被改变,但并不代表初始化新实体的时候会自动使用数据库默认值。当尝试创建新的 Message 实体且未设置 sent_at 属性时,PHP 对象的 $sent_at 仍然会是 null,最终 Doctrine 会将这个 null 写入数据库,触发 NOT NULL 的约束错误。数据库层面具备 DEFAULT 值功能,但实体层并没有与之配合实现数据的自动填充。

解决方案:手动赋值与生命周期事件

为了让 sent_at 字段能如期使用 CURRENT_TIMESTAMP 填充,并且无需每次都手动赋值,存在多种方法解决。最常用且直接的方法就是:在实体创建时利用 Doctrine 的生命周期事件或是直接在构造器中完成赋值。

方案一:构造函数赋值

这是最直接且容易理解的方法,直接在实体的构造函数中为未初始化的 DateTimeImmutable 类型变量赋值:

#[Entity]
#[Table(name: 'messages')]
class Message
{
    #[Column(name: 'sent_at', type: Types::DATETIME_IMMUTABLE, updatable: false)]
    private readonly DateTimeImmutable $sent_at;

    public function __construct()
    {
        $this->sent_at = new \DateTimeImmutable();
    }
    ...
}

操作步骤:

  1. Message 类中添加构造函数 __construct()
  2. 在构造函数中,使用 new DateTimeImmutable() 创建当前时间戳实例,赋值给 $sent_at 属性。
  3. 当通过 new Message() 创建新实体时,$sent_at 会自动初始化为当前时间。

此方案避免了在每个插入的地方都需要处理时间字段,保证了一致性,但对于逻辑更复杂的应用来说不够优雅。

方案二:PrePersist 生命周期事件

Doctrine 提供了生命周期回调机制,允许开发者在特定的持久化操作发生之前或之后执行代码。 使用 @PrePersist 注解可以监听实体持久化之前的事件,从而设置时间戳:

#[Entity]
#[Table(name: 'messages')]
class Message
{
    #[Column(name: 'sent_at', type: Types::DATETIME_IMMUTABLE, updatable: false)]
    private DateTimeImmutable $sent_at; //Remove readonly here

    #[PrePersist]
    public function prePersist()
    {
        if (!$this->sent_at) {
             $this->sent_at = new \DateTimeImmutable();
        }
    }
     //Add setSentAt method or make field not readonly.
    public function setSentAt(DateTimeImmutable $sentAt)
    {
         $this->sent_at = $sentAt;
    }
   // Add get method if needed.
     public function getSentAt(): DateTimeImmutable {
          return $this->sent_at;
    }
}

操作步骤:

  1. 去掉 readonly 标识,为 $sent_at 添加一个 setSentAt 方法,以便可以对其进行设置。
  2. 在类中添加一个使用 #[PrePersist] 注解的 prePersist() 方法。
  3. prePersist() 方法中检查 $sent_at 属性是否为空。若为空,使用 new \DateTimeImmutable() 进行初始化。
  4. 现在即使你在创建实体时不设置 $sent_at, 在数据库操作时,也会通过生命周期事件将值填充为当前时间。

使用生命周期回调机制可以保证更灵活的代码架构,避免实体过于臃肿。 如果只需要保证在创建新实体时注入时间,生命周期事件是一个优秀的选择。

安全提示

无论是使用构造函数还是生命周期事件,都必须考虑到时区的问题, 尤其是在跨时区的应用中。 如果你处理多时区的数据,你需要在代码中设置正确时区,避免数据存储的时区与预期不符。

new DateTimeImmutable('now', new DateTimeZone('Your/TimeZone'));

结语
Doctrine 中的 'default' => 'CURRENT_TIMESTAMP' 主要服务于数据库结构定义层面。 为解决实体默认时间戳的问题,需结合构造函数或 Doctrine 的生命周期事件,在数据被持久化之前主动为相关字段赋值,才能达到预期的效果,且避免产生NULL值,从而有效地规避数据库约束错误。在编写代码时应该结合实际应用场景以及对性能、代码维护等方面的考虑来选择合适的解决方案。