118 lines
3.5 KiB
PHP
118 lines
3.5 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace PhpQml\Bridge;
|
||
|
|
|
||
|
|
use PhpQml\Bridge\Attribute\BridgeResource;
|
||
|
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Translates Doctrine entity lifecycle events into Mercure envelopes.
|
||
|
|
*
|
||
|
|
* For each change, dual-publishes to:
|
||
|
|
* - `app://model/{name}` — collection topic, watched by ReactiveListModel
|
||
|
|
* - `app://model/{name}/{id}` — entity topic, watched by ReactiveObject
|
||
|
|
*
|
||
|
|
* Topic / envelope shape per PLAN.md §4. The `correlationKey` echoes
|
||
|
|
* the originating request's `Idempotency-Key` (§5 *Optimistic updates*).
|
||
|
|
*
|
||
|
|
* Phase 2 uses a per-process counter for the envelope `version` field
|
||
|
|
* — sufficient for single-instance dev mode. Phase 4 / production should
|
||
|
|
* back this with a persistent monotonic source (e.g. Postgres SEQUENCE).
|
||
|
|
*/
|
||
|
|
final class ModelPublisher
|
||
|
|
{
|
||
|
|
/** @var array<string, int> */
|
||
|
|
private array $versions = [];
|
||
|
|
|
||
|
|
public function __construct(
|
||
|
|
private readonly Publisher $publisher,
|
||
|
|
private readonly CorrelationContext $correlationContext,
|
||
|
|
private readonly NormalizerInterface $normalizer,
|
||
|
|
) {
|
||
|
|
}
|
||
|
|
|
||
|
|
public function publishEntityChange(object $entity, string $op): void
|
||
|
|
{
|
||
|
|
$resource = $this->resolveResource($entity);
|
||
|
|
if (null === $resource) {
|
||
|
|
return; // not a #[BridgeResource]
|
||
|
|
}
|
||
|
|
|
||
|
|
$name = $resource->name ?? self::deriveName($entity::class);
|
||
|
|
$id = (string) $this->extractId($entity);
|
||
|
|
|
||
|
|
$envelope = [
|
||
|
|
'op' => $op,
|
||
|
|
'id' => $id,
|
||
|
|
'version' => $this->nextVersion($name),
|
||
|
|
'data' => 'delete' === $op ? null : $this->normalize($entity),
|
||
|
|
];
|
||
|
|
|
||
|
|
if (null !== $key = $this->correlationContext->get()) {
|
||
|
|
$envelope['correlationKey'] = $key;
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->publisher->publish("app://model/{$name}", $envelope);
|
||
|
|
$this->publisher->publish("app://model/{$name}/{$id}", $envelope);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function resolveResource(object $entity): ?BridgeResource
|
||
|
|
{
|
||
|
|
$reflection = new \ReflectionClass($entity);
|
||
|
|
$attrs = $reflection->getAttributes(BridgeResource::class);
|
||
|
|
if ([] === $attrs) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $attrs[0]->newInstance();
|
||
|
|
}
|
||
|
|
|
||
|
|
private function extractId(object $entity): mixed
|
||
|
|
{
|
||
|
|
if (method_exists($entity, 'getId')) {
|
||
|
|
return $entity->getId();
|
||
|
|
}
|
||
|
|
|
||
|
|
$r = new \ReflectionClass($entity);
|
||
|
|
if ($r->hasProperty('id')) {
|
||
|
|
$prop = $r->getProperty('id');
|
||
|
|
$prop->setAccessible(true);
|
||
|
|
|
||
|
|
return $prop->getValue($entity);
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new \LogicException(\sprintf('Cannot extract id from %s: no getId() method or $id property.', $entity::class));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array<string, mixed>
|
||
|
|
*/
|
||
|
|
private function normalize(object $entity): array
|
||
|
|
{
|
||
|
|
/** @var array<string, mixed> $data */
|
||
|
|
$data = $this->normalizer->normalize($entity, 'json', [
|
||
|
|
'circular_reference_handler' => static fn (object $o): string => (string) ($o->getId() ?? ''),
|
||
|
|
]);
|
||
|
|
|
||
|
|
return $data;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function nextVersion(string $name): int
|
||
|
|
{
|
||
|
|
if (!isset($this->versions[$name])) {
|
||
|
|
$this->versions[$name] = (int) (microtime(true) * 1_000_000);
|
||
|
|
}
|
||
|
|
|
||
|
|
return ++$this->versions[$name];
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function deriveName(string $fqcn): string
|
||
|
|
{
|
||
|
|
$basename = substr($fqcn, (int) strrpos($fqcn, '\\') + 1);
|
||
|
|
|
||
|
|
return strtolower($basename);
|
||
|
|
}
|
||
|
|
}
|