如何验证数据映射器模式中的唯一性



使用数据映射器模式:

  • 一个对象/实体不知道数据映射器和存储(例如RDBMS)。
  • 存储不知道数据映射器和对象/实体。
  • 数据映射器当然很清楚并桥接对象/实体和存储。

我如何验证对象/实体中的唯一字段(例如$user->name),而无需了解数据映射器和存储(即$user不能简单地调用$userDataMapper->count('name='.$this->name))?

class User
{
    private $name; // unique
    public function validate(): bool
    {
        // what to put here to validate that $this->name
        // is unique in column `users`.`name`?
    }
}

到目前为止,我知道有两种潜在的解决方案(Tereško提出了一种),但两者都有缺点。

Tereško建议的第一个是捕获PdoException。

class UserDataMapper
{
    public function store($user)
    {
        $sql = 'INSERT INTO `users` SET `name` = :name, `email_address` = :emailAddress...';
        $params =
        [
            'name' => $user->getName(),
            'emailAddress' => $user->getEmailAddress(),
            // ...
        ];
        $statement = $this->connection->prepare($sql);
        try
        {
            $statement->execute($params);
        }
        catch (PDOException $e)
        {
            if ($e->getCode() === 23000)
            {
                // problem: can only receive one unique error at a time.
                // parse error message and throw corresponding exception.
                if (...name error...)
                {
                    thrown new NameAlreadyRegistered;
                }
                elseif (...email address error...)
                {
                    thrown new EmailAlreadyRegistered;
                }
            }
            throw $e; // because if this happens, you missed something
        }
    }
}
// Controller
class Register
{
    public function run()
    {
        if ($user->validate()) // first step of validation
        {
            // second step of validation
            try
            {
                $this->userDataMapper->store($this->user);
            }
            catch (NameAlreadyRegistered $e)
            {
                $this->errors->add(... NameAlreadyRegistered ...)
            }
            catch (EmailAlreadyRegistered $e)
            {
                $this->errors->add(... EmailAlreadyRegistered ...)
            }
            // ...other catches...
        }
        else
        {
            $this->errors = $user->getErrors();
        }
    }
}

问题在于,这将在两个位置分配验证,即在实体(用户)和datamapper/Controller内部(由datamapper检测并传递给控制器以记录)。另外,DatamApper可以捕获并处理异常/MySQL错误代码,但这违反了单一责任,同时又不减轻"拆分验证"问题。

此外,PDO/MySQL一次只能丢弃一个错误。如果有两个或更多独特的列,我们一次只能"验证"其中一个。

在两个地方进行拆分验证的另一个后果是,如果以后,我们想添加更多唯一的列,然后再加上用户实体,我们还必须修改寄存器控制器(以及ChangeEmailAddress和Change -Profile Controllers等)。

第二种方法是我目前正在使用的方法,它是将验证分为单独的对象。

Class UserValidation
{
    public function validate()
    {
        if ($this->userDataMapper->count('name='.$user->getName() > 0))
        {
            $this->errors->add(...NameAlreadyRegistered...);
        }
        if ($this->userDataMapper->count('email_address='.$user->getEmailAddress() > 0))
        {
            $this->errors->add(...EmailAlreadyRegistered...);
        }
    }
}
// Controller
class Register
{
    public function run()
    {
        if ($this->userValidation()->validate())
        {
            $this->userDataMapper()->store($user);
        }
        else
        {
            $this->errors = $this->userValidation()->getErrors();
        }
    }
}

这有效。直到扩展实体为止。

class SpecialUser extends User
{
    private $someUniqueField;
}
// need to extend the UserValidation to incorporate the new field(s) too.
class SpecialUserValidation extends UserValidation
{
    public function validate()
    {
        parent::validate();
        // ...validate $this->user->someUniqueField...
    }
}

对于每个实体子类,需要一个验证子类。

所以,我们回到了我的最初问题。如何(正确)验证数据映射器模式中的唯一性?

为什么要做RDBMS的工作?除非您使用一些过时的SQL连接抽象API(例如死者,但没有被遗忘的ext/mysql),否则尝试违反UNIQUE约束将导致异常。

因此,您的数据映射器应该捕获该异常(假设使用PDO,因此将是PDOException),找出错误代码,然后将其重新插入为适当的业务域例外。就是这样。

然后可以在服务层处理该域异常。

您的datamapper应该不对数据完整性检查负责。这些由RDBMS的CONSTRAINT定义处理。当然,可用约束的范围将取决于您使用的RDBMS。

namespace ModelMapper;
use ModelEntity;
use ModelException;
use ComponentDataMapper
class User extends DataMapper 
{
    // DB $this->connection passing is probably shared, so it's nice to just leave it in superclass
    public function store(EntityUser $user)
    {
        $statement = $this->connection->prepare('INSERT INTO ...');
        $statement->bindValue(':email', $user->getEmailAddress());
        try {
            $statement->execute();
        } catch (PDOException $e) {
            if ($e->getCode() === 23000) {
                thrown new ExceptionEmailAlreadyRegistered;
            }
            throw $e; // because if this happens, you missed something
        }
    }
}

我认为这很棘手。基于Martin Fowlers的定义:

一层映射器(473),该映射器在对象和一个之间移动数据 数据库使它们彼此独立和映射器 本身。

看来您的解决方案是正确的,因为您的业务域User不知道任何SQL,Mapper不了解您的业务域User,而且完全脱钩了,因为它都不知道另一个。

imo UserMapper应该绝对意识到DB,因为这是工作:

class UserDataMapper
{
    private $db; // this should be avoided?
    private $name;
    public function __construct($db, $name)
    {
        $this->db; // bad? NOPE!
        $this->name = $name;
    }
    public function validate()
    {
        if ($this->db->count('name='.$this->name) > 0)
            return false;
    }
}

但是,互换数据的问题是,从您的实体User传递给数据映射器的是什么?现在,它只需要传递名称,但将来很可能还有许多其他领域。幸运的是,干净的体系结构有关于通过的建议。


现在,验证问题是并发,如果两个并发的Apache线程/进程正在创建具有相同名称的用户,则两者都可以得到计数== 0,在这种情况下,需要某种唯一的唯一table.name创建的限制,因此只有一个插入成功!