在具有主键类型 UUID 的 Laravel/Eloquent 关系上创建相关模型

Creating related Models on Laravel/Eloquent relations with primary key type UUID

Laravel: 7; PHP: 7.4/8.0

在我的项目中,我有两个相关模型:UserTimeAccount。两者都使用 UUID 作为主键,键名仍然是 id.

迁移:

class CreateUsersTable extends Migration
{
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->uuid('id')->primary();
            // snip...
        });
    }
    
    // snip...
}
class CreateTimeAccountsTable extends Migration
{
    public function up()
    {
        Schema::create('time_accounts', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->foreignUuid('user_id')->nullable()->constrained();
            // snip...
        });
    }
    
    // snip...
}

型号:

class User
{
    use IsIdentifiedByUuid;

    protected $keyType = 'string';

    public function timeAccount()
    {
        return $this->hasOne(TimeAccount::class);
    }

    // snip...
}
class TimeAccount
{
    use IsIdentifiedByUuid;

    protected $keyType = 'string';

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    // snip...
}

IsIdentifiedByUuid 特征:

trait IsIdentifiedByUuid
{
    protected static function bootIsIdentifiedByUuid()
    {
        static::creating(fn ($model) => static::isIdentifiedByUuidCreatingHandler($model));
        static::saving(fn ($model) => static::isIdentifiedByUuidSavingHandler($model));
    }

    public function initializeIsIdentifiedByUuid()
    {
        $this->keyType = 'string';
    }

    protected function getUuidColumn(): string
    {
        return property_exists($this, 'uuid_column') ? $this->uuid_column : 'id';
    }

    protected static function getNewUuid(): UuidInterface
    {
        $columnName = app(static::class)->getUuidColumn();
        $tableName = app(static::class)->getTable();
        $columnDescriptor = "$tableName.$columnName";
        $uuid = "";
        $query = \DB::table($tableName)
            ->select($columnDescriptor)
            ->where($columnDescriptor, $uuid);
        $attempts = 0;
        do {
            if ($attempts >= \App\Constants\Uuid::MAX_ATTEMPTS) {
                throw new UuidMaxGeneratorAttemptsExceededException();
            }
            $uuid = Str::uuid();
            $attempts++;
        } while ($query->setBindings([ $uuid->toString() ])->count() > 0);

        return $uuid;
    }

    /**
     * Handles the creation of a model.
     * - Generates new UUID if the UUID column is empty and auto-increment is enabled
     *
     * @param Model $model
     * @throws \App\Exceptions\UuidMaxGeneratorAttemptsExceededException
     */
    protected static function isIdentifiedByUuidCreatingHandler(Model $model)
    {
        $columnName = $model->getUuidColumn();
        if ($model->getIncrementing() && !$model->{$columnName}) {
            $uuid = static::getNewUuid();
            \Log::debug(
                "IsIdentifiedByUuid [CREATING]:" .
                " Generating new UUID for `" . get_class($model) . ": $uuid`"
            );
            $model->{$columnName} = $uuid->toString();
        } else {
            \Log::debug(
                "IsIdentifiedByUuid [CREATING]:" .
                " Using existing UUID for `" . get_class($model) . ": $model->{$columnName}`"
            );
        }
    }

    /**
     * Handles the saving of a Model.
     * - Prevents changes to the UUID column
     * - Rolls back changed value to old value
     *
     * @param Model $model
     */
    protected static function isIdentifiedByUuidSavingHandler(Model $model)
    {
        $columnName = $model->getUuidColumn();
        $originalUuid = $model->getOriginal($columnName);
        if (!is_null($originalUuid) &&
            $originalUuid !== $model->{$columnName}
        ) {
            \Log::debug(
                "IsIdentifiedByUuid [SAVING]:" .
                " Prevented change of UUID for `" . get_class($model) . ":$originalUuid`"
            );
            $model->{$columnName} = $originalUuid;
        }
    }
}

现在的问题是:

在创建的挂钩上的用户观察者中,我正在这样做:


$userTimeAccount = $user->timeAccount()->create([
    // snip...
]);

到目前为止 - 在切换到 UUID 而不是整数键之前 - 一切正常!据我所知,在保存用户模型之前(显然是在触发 auto-inc 之前)创建相关模型是 eloquent/Laravel 明确允许的(尽管我找不到文档的部分).

切换到 UUID 后,这不再有效,我收到 SQL 错误:

SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (laravel_torus.time_accounts, CONSTRAINT time_accounts_user_id_foreign FOREIGN KEY (user_id) REFERENCES users (id)) (SQL: insert into time_accounts (time_account_type_id, balance, borrow, user_id, id, updated_at, created_at) values (3, 0, 0, 0, a995807b-b7e1-4af3-96de-7c48187f943d, 2021-07-09 12:54:25, 2021-07-09 12:54:25))

SQL美化:

insert into `time_accounts` 
    (`time_account_type_id`, `balance`, `borrow`, `user_id`, `id`, `updated_at`, `created_at`)
    values 
    (3, 0, 0, 0, 'bad86e70-7496-4b42-b2a1-5cc3c5a4d06c', '2021-07-09 11:19:17', '2021-07-09 11:19:17');

不知何故 Laravel 试图为外键插入零(整数),这显然不起作用。我什至尝试手动将 keyType 设置为 'string' 但没有效果。

我哪里错了?


编辑: 似乎存在竞争条件。 Observer 的 created 钩子将在 User 模型的 created 钩子之前被触发。这会导致缺少 ID - 使用整数和自动递增时会出现 ID,正如它应该的那样。

TL;DR

I was not able to solve this with MySQL and switched to Postgres SQL.

DONT USE UUID WITH MYSQL unless you are, absolutely sure that: 1. you need to and 2. the performance impact will be negligible!


我真的无法解释这个错误是怎么发生的。有问题的 ID 已经存在(使用调试器查看模型的 $attribnutes 数组),调用 $user->id 时仍然 Laravel 将 return 整数 0

在调查问题时,我注意到 Laravel 正在为 UUID 而不是二进制创建字符串列(这是我所期望的)。由于与 integer/binary 比较相比,字符串比较的成本非常高,所以我决定放弃 MySQL。 UUID 对于此项目是强制性的,并且已经打算在将来从 MySQL 转移到另一个 DBMS。

Postgres SQL 支持特殊的列类型 uuid 如果定义了 UUID 列,Laravel 将使用它。这不仅将 ID 存储和比较为二进制,而且还检查格式和唯一值,这意味着您不能像在 MySQL 常规 sting 列中那样意外输入无效的 UUID,除了not nullunique.