Skip to content

🐛 Entity with composite PK of UUIDs isn't inserted properly #411

@nevmerzhitsky

Description

@nevmerzhitsky

No duplicates 🥲.

  • I have searched for a similar issue in our bug tracker and didn't find any solutions.

Database

PostgreSQL

What happened?

I use UUID as the PK of every table in my DB (via my custom mapper). The problem appears when I try to define an entity which is an association table (it represents the many-to-many relation). So it contains just two fields customer and telegramChat:

use Cycle\Annotated\Annotation as Cycle;

#[Cycle\Entity(mapper: \Cycle\ORM\Mapper\Mapper::class)]
#[Cycle\Table\Index(['telegram_chat_id'], unique: true)]
class CustomerTelegramChat
{
    #[Cycle\Column(type: 'uuid', name: 'customer_id', primary: true)]
    #[Cycle\Relation\BelongsTo(target: Customer::class, innerKey: 'customer_id')]
    private Customer $customer;

    #[Cycle\Column(type: 'uuid', name: 'telegram_chat_id', primary: true)]
    #[Cycle\Relation\BelongsTo(target: TelegramChat::class, innerKey: 'telegram_chat_id')]
    private TelegramChat $telegramChat;

...
}
use App\Entities\Traits\SoftDeleteTrait;
use App\Entities\Traits\TimestampsTraits;
use Cycle\Annotated\Annotation as Cycle;

#[Cycle\Entity]
class Customer
{
    use TimestampsTraits;
    use SoftDeleteTrait;

    #[Cycle\Column(type: 'uuid', primary: true)]
    private string $id;

...
}
use App\Entities\Traits\SoftDeleteTrait;
use App\Entities\Traits\TimestampsTraits;
use Cycle\Annotated\Annotation as Cycle;

#[Cycle\Entity]
class TelegramChat
{
    use TimestampsTraits;
    use SoftDeleteTrait;

    #[Cycle\Column(type: 'uuid', primary: true)]
    private string $id;

    #[Cycle\Column(type: 'bigInteger')]
    private int $telegramId;

    #[Cycle\Column(type: 'enum(private,group,supergroup,channel)')]
    private string $type;

    #[Cycle\Column(type: 'string', nullable: true)]
    private ?string $title;

    #[Cycle\Column(type: 'string', nullable: true)]
    private ?string $username;

    #[Cycle\Column(type: 'string', nullable: true)]
    private ?string $firstName;

    #[Cycle\Column(type: 'string', nullable: true)]
    private ?string $lastName;

...
}

The usage code looks like this:

        $chat = new TelegramChat(...);

        /** @var ?CustomerTelegramChat $link */
        $link = $this->orm->getRepository(CustomerTelegramChat::class)->select()
            ->where('telegram_chat_id', $chat->getId())
            ->fetchOne();

        if (!is_null($link)) {
            $customer = $link->getCustomer();
        } else {
            $customer = new Customer();
            $link = new CustomerTelegramChat($customer, $chat);
        }

        $em->persist($customer)->run();
        dump($customer);
        dump($link);
        $em->persist($link)->run();
        dump($link);

After the second run() call there is not a new row in the customer_telegram_chat table.

I've added a few dump() calls into DatabaseMapper::queueCreate()

    public function queueCreate(object $entity, Node $node, State $state): CommandInterface
    {
        $values = $state->getData();
        dump($state, $values);

        // sync the state
        $state->setStatus(Node::SCHEDULED_INSERT);

        foreach ($this->primaryKeys as $key) {
            dump($key);
            if (!isset($values[$key])) {
                foreach ($this->nextPrimaryKey() ?? [] as $pk => $value) {
                    $state->register($pk, $value);
                }
                break;
            }
        }
...

So the total debug log looks like this:

Cycle\ORM\Heap\State {#6823 // vendor/cycle/orm/src/Mapper/DatabaseMapper.php:111
  -transactionData: []
  -relationStatus: []
  -storage: []
  -state: 3
  -data: array:2 [
    "updatedAt" => null
    "deletedAt" => null
  ]
  -transactionRaw: []
  -relations: []
  #waitingFields: []
}
array:2 [ // vendor/cycle/orm/src/Mapper/DatabaseMapper.php:111
  "updatedAt" => null
  "deletedAt" => null
]
"id" // vendor/cycle/orm/src/Mapper/DatabaseMapper.php:117
App\Entities\Customer {#6251 // app/TelegramBot/Listeners/UpdateListener.php:41
  -id: Ramsey\Uuid\Lazy\LazyUuidFromString {#6421
    -unwrapped: null
    -uuid: "42543529-f8c4-4836-9bc2-a89bdce5f66c"
    uuid: "42543529-f8c4-4836-9bc2-a89bdce5f66c"
  }
  -createdAt: DateTimeImmutable @1683280564 {#6822
    date: 2023-05-05 09:56:04.849477 UTC (+00:00)
  }
  -updatedAt: DateTimeImmutable @1683280564 {#6822}
  +deletedAt: null
}
App\Entities\CustomerTelegramChat {#6245 // app/TelegramBot/Listeners/UpdateListener.php:42
  -customer: App\Entities\Customer {#6251
    -id: Ramsey\Uuid\Lazy\LazyUuidFromString {#6421
      -unwrapped: null
      -uuid: "42543529-f8c4-4836-9bc2-a89bdce5f66c"
      uuid: "42543529-f8c4-4836-9bc2-a89bdce5f66c"
    }
    -createdAt: DateTimeImmutable @1683280564 {#6822
      date: 2023-05-05 09:56:04.849477 UTC (+00:00)
    }
    -updatedAt: DateTimeImmutable @1683280564 {#6822}
    +deletedAt: null
  }
  -telegramChat: App\Entities\TelegramChat Cycle ORM Proxy {#6302
    +deletedAt: null
    +__cycle_orm_rel_map: Cycle\ORM\RelationMap {#6299
      -innerRelations: []
      -dependencies: []
      -slaves: []
      -embedded: []
    }
    +__cycle_orm_relation_props: Cycle\ORM\Mapper\Proxy\Hydrator\PropertyMap {#6500
      -class: "App\Entities\TelegramChat Cycle ORM Proxy"
      -properties: []
    }
    +__cycle_orm_rel_data: []
    -id: "e17df7a0-0487-4e31-b198-666b105c9736"
    -telegramId: 76953481
    -type: "private"
    -title: null
    -username: "nevmerzhitsky"
    -firstName: "Sergey"
    -lastName: "Nevmerzhitsky"
    -createdAt: DateTimeImmutable @1683237412 {#6323
      date: 2023-05-04 21:56:52.0 UTC (+00:00)
    }
    -updatedAt: DateTimeImmutable @1683237412 {#6326
      date: 2023-05-04 21:56:52.0 UTC (+00:00)
    }
    deletedAt: null
  }
}
Cycle\ORM\Heap\State {#6372 // vendor/cycle/orm/src/Mapper/DatabaseMapper.php:111
  -transactionData: []
  -relationStatus: array:2 [
    "customer" => 3
    "telegramChat" => 3
  ]
  -storage: []
  -state: 3
  -data: []
  -transactionRaw: []
  -relations: array:2 [
    "customer" => App\Entities\Customer {#6251
      -id: Ramsey\Uuid\Lazy\LazyUuidFromString {#6421
        -unwrapped: null
        -uuid: "42543529-f8c4-4836-9bc2-a89bdce5f66c"
        uuid: "42543529-f8c4-4836-9bc2-a89bdce5f66c"
      }
      -createdAt: DateTimeImmutable @1683280564 {#6822
        date: 2023-05-05 09:56:04.849477 UTC (+00:00)
      }
      -updatedAt: DateTimeImmutable @1683280564 {#6822}
      +deletedAt: null
    }
    "telegramChat" => App\Entities\TelegramChat Cycle ORM Proxy {#6302
      +deletedAt: null
      +__cycle_orm_rel_map: Cycle\ORM\RelationMap {#6299
        -innerRelations: []
        -dependencies: []
        -slaves: []
        -embedded: []
      }
      +__cycle_orm_relation_props: Cycle\ORM\Mapper\Proxy\Hydrator\PropertyMap {#6500
        -class: "App\Entities\TelegramChat Cycle ORM Proxy"
        -properties: []
      }
      +__cycle_orm_rel_data: []
      -id: "e17df7a0-0487-4e31-b198-666b105c9736"
      -telegramId: 76953481
      -type: "private"
      -title: null
      -username: "nevmerzhitsky"
      -firstName: "Sergey"
      -lastName: "Nevmerzhitsky"
      -createdAt: DateTimeImmutable @1683237412 {#6323
        date: 2023-05-04 21:56:52.0 UTC (+00:00)
      }
      -updatedAt: DateTimeImmutable @1683237412 {#6326
        date: 2023-05-04 21:56:52.0 UTC (+00:00)
      }
      deletedAt: null
    }
  ]
  #waitingFields: []
}
[] // vendor/cycle/orm/src/Mapper/DatabaseMapper.php:111
"customer" // vendor/cycle/orm/src/Mapper/DatabaseMapper.php:117
App\Entities\CustomerTelegramChat {#6245 // app/TelegramBot/Listeners/UpdateListener.php:46
  -customer: App\Entities\Customer {#6251
    -id: Ramsey\Uuid\Lazy\LazyUuidFromString {#6421
      -unwrapped: null
      -uuid: "42543529-f8c4-4836-9bc2-a89bdce5f66c"
      uuid: "42543529-f8c4-4836-9bc2-a89bdce5f66c"
    }
    -createdAt: DateTimeImmutable @1683280564 {#6822
      date: 2023-05-05 09:56:04.849477 UTC (+00:00)
    }
    -updatedAt: DateTimeImmutable @1683280564 {#6822}
    +deletedAt: null
  }
  -telegramChat: App\Entities\TelegramChat Cycle ORM Proxy {#6302
    +deletedAt: null
    +__cycle_orm_rel_map: Cycle\ORM\RelationMap {#6299
      -innerRelations: []
      -dependencies: []
      -slaves: []
      -embedded: []
    }
    +__cycle_orm_relation_props: Cycle\ORM\Mapper\Proxy\Hydrator\PropertyMap {#6500
      -class: "App\Entities\TelegramChat Cycle ORM Proxy"
      -properties: []
    }
    +__cycle_orm_rel_data: []
    -id: "e17df7a0-0487-4e31-b198-666b105c9736"
    -telegramId: 76953481
    -type: "private"
    -title: null
    -username: "nevmerzhitsky"
    -firstName: "Sergey"
    -lastName: "Nevmerzhitsky"
    -createdAt: DateTimeImmutable @1683237412 {#6323
      date: 2023-05-04 21:56:52.0 UTC (+00:00)
    }
    -updatedAt: DateTimeImmutable @1683237412 {#6326
      date: 2023-05-04 21:56:52.0 UTC (+00:00)
    }
    deletedAt: null
  }
}

The last entry (marked by app/TelegramBot/Listeners/UpdateListener.php:46) is the last dump() call. So it looks fully proper to be inserted in the table, but it's not happened and no errors were reported.

I've experimented a lot with this problem and I've tried to use my custom Mapper which generates UUID PK primary keys (which I use for all my other entities) as mentioned for the 1.x version of the cycle-database: https://cycle-orm.dev/docs/advanced-uuid/1.x/en#mapper

use Cycle\ORM\Exception\MapperException;
use Cycle\ORM\Mapper\Mapper;
use Exception;
use Ramsey\Uuid\Uuid;

class UuidMapper extends Mapper
{
    public function nextPrimaryKey(): ?array
    {
        try {
            return collect($this->primaryKeys)
                ->mapWithKeys(function ($attribute) {
                    $value = Uuid::uuid4()->toString();
                    return [$attribute => $value];
                })
                ->toArray();
        } catch (Exception $e) {
            throw new MapperException($e->getMessage(), $e->getCode(), $e);
        }
    }
}

The big problem here is that the mapper always tries to override the real value of customer[_id] and telegram_chat[_id] before the insert and so it broke the proper FK of CustomerTelegramChat. It was the reason why I switched the entity to the default mapper. I think this is a bug that the nextPrimaryKey() method has no access to actual field values to be able to check that the null must be returned instead of a new UUID generation (this may fix unwanted overriding of the real values).

Also, I've checked docs about composite PKs in the cycle-database 2.x version: https://cycle-orm.dev/docs/advanced-composite-pk/2.x/en#declaration-via-annotations. But I'm not sure how to mix the #[Column] and the #[BelongsTo] annotations together in the case of the composite key. I want my entity to support methods like setCustomer() and getCustomer() with eager load, not just setCustomerId(), etc.

Also, I've checked docs about UUID in the cycle-database 2.x version: https://cycle-orm.dev/docs/entity-behaviors-uuid/2.x/en#version-4-random. But I cannot apply this solution because #[UuidX] annotations cannot be used to the composite PK because these annotations are not repeatable so only one can be declared per class (it's a very strange limitation).

Sorry, I'm totally bloated up by the situation. Can you provide a guide on how to make a many-to-many table with composite UUIDs PK in cycle-database 2.x? Should I implement my own mapper with nextPrimaryKey() to use UUID or not?

Version

PHP 8.2.5
laravel/framework                 10.9.0
cycle/annotated                   v3.2.1
cycle/database                    2.4.1
cycle/entity-behavior             1.1.1
cycle/entity-behavior-uuid        1.0.0
cycle/migrations                  v4.0.1
cycle/orm                         v2.3.1
cycle/schema-builder              v2.3.0
cycle/schema-migrations-generator 2.1.0

Metadata

Metadata

Type

No type

Projects

Status

Released

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions