2026-05-02 02:32:51 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace PhpQml\Bridge;
|
|
|
|
|
|
|
|
|
|
use PhpQml\Bridge\Attribute\BridgeResource;
|
|
|
|
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-03 19:50:01 +02:00
|
|
|
* Default implementation of {@see ModelPublisherInterface}: dual-publishes
|
|
|
|
|
* each change to the collection and entity topics for a `#[BridgeResource]`
|
|
|
|
|
* entity.
|
2026-05-02 02:32:51 +02:00
|
|
|
*
|
2026-05-03 19:50:01 +02:00
|
|
|
* For each change, publishes to:
|
2026-05-02 02:32:51 +02:00
|
|
|
* - `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*).
|
|
|
|
|
*
|
2026-05-03 19:50:01 +02:00
|
|
|
* Uses a per-process counter for the envelope `version` field — sufficient
|
|
|
|
|
* for single-instance bundled mode. Multi-instance / production deployments
|
|
|
|
|
* should back this with a persistent monotonic source (e.g. Postgres
|
|
|
|
|
* SEQUENCE); deferred to v0.2.0+ §13.
|
2026-05-02 02:32:51 +02:00
|
|
|
*/
|
2026-05-03 19:50:01 +02:00
|
|
|
final class ModelPublisher implements ModelPublisherInterface
|
2026-05-02 02:32:51 +02:00
|
|
|
{
|
|
|
|
|
/** @var array<string, int> */
|
|
|
|
|
private array $versions = [];
|
|
|
|
|
|
|
|
|
|
public function __construct(
|
2026-05-03 19:50:01 +02:00
|
|
|
private readonly PublisherInterface $publisher,
|
|
|
|
|
private readonly CorrelationContextInterface $correlationContext,
|
2026-05-02 02:32:51 +02:00
|
|
|
private readonly NormalizerInterface $normalizer,
|
|
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:50:01 +02:00
|
|
|
public function publishEntityChange(object $entity, BridgeOp $op): void
|
2026-05-02 02:32:51 +02:00
|
|
|
{
|
|
|
|
|
$resource = $this->resolveResource($entity);
|
|
|
|
|
if (null === $resource) {
|
|
|
|
|
return; // not a #[BridgeResource]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$name = $resource->name ?? self::deriveName($entity::class);
|
|
|
|
|
$id = (string) $this->extractId($entity);
|
|
|
|
|
|
|
|
|
|
$envelope = [
|
2026-05-03 19:50:01 +02:00
|
|
|
'op' => $op->value,
|
2026-05-02 02:32:51 +02:00
|
|
|
'id' => $id,
|
|
|
|
|
'version' => $this->nextVersion($name),
|
2026-05-03 19:50:01 +02:00
|
|
|
'data' => BridgeOp::Delete === $op ? null : $this->normalize($entity),
|
2026-05-02 02:32:51 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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')) {
|
2026-05-03 19:50:01 +02:00
|
|
|
return $r->getProperty('id')->getValue($entity);
|
2026-05-02 02:32:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|