From 1d014ae3b7fbc7405a09b6aa90d51f4d358f93f4 Mon Sep 17 00:00:00 2001 From: magdev Date: Sun, 3 May 2026 20:28:50 +0200 Subject: [PATCH] v0.2.0 (8/N): make:bridge:read-model maker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements PLAN.md §8's fourth makers-table row. Read-models are server-side projections — joined fetches, aggregates, denormalised views — that QML reads without going through a writable #[BridgeResource]. The maker emits: - src/ReadModel/ReadModel.php — query service stub injecting EntityManagerInterface; user fills query() with DQL / QueryBuilder / raw SQL as fits. - src/Controller/Controller.php — single GET handler at /api/, just normalises the read-model output to JSON. - {qml_path}/List.qml — ReactiveListModel bound to the route, deliberately no Mercure topic. The "no topic" choice is the design call worth documenting: read-models are queries, not reactive resources, and pretending otherwise would either auto-publish stale aggregates on every entity change or require the user to invent invalidation logic in the listener. Better: pair the read-model with `make:bridge:event` and call refresh() from the QML event-handler when the underlying data really changes. Naming convention: kebab-PLURAL routes (`/api/todo-summaries`) for consistency with REST list semantics; resource path stays singular under `src/ReadModel/`. Wired into services.yaml's when@dev block. Three new snapshot baselines (TodoSummaryReadModel.php / TodoSummaryController.php / TodoSummaryList.qml) plus runner extension. All 14 maker outputs verify on the committed state. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + framework/php/config/services.yaml | 4 + .../php/src/Maker/BridgeReadModelMaker.php | 143 ++++++++++++++++++ .../php/src/Maker/templates/ReadModel.tpl.php | 38 +++++ .../templates/ReadModelController.tpl.php | 29 ++++ .../src/Maker/templates/ReadModelQml.tpl.php | 28 ++++ .../tests/snapshot/TodoSummaryController.php | 28 ++++ .../php/tests/snapshot/TodoSummaryList.qml | 28 ++++ .../tests/snapshot/TodoSummaryReadModel.php | 37 +++++ framework/php/tests/snapshot/run.sh | 20 ++- 10 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 framework/php/src/Maker/BridgeReadModelMaker.php create mode 100644 framework/php/src/Maker/templates/ReadModel.tpl.php create mode 100644 framework/php/src/Maker/templates/ReadModelController.tpl.php create mode 100644 framework/php/src/Maker/templates/ReadModelQml.tpl.php create mode 100644 framework/php/tests/snapshot/TodoSummaryController.php create mode 100644 framework/php/tests/snapshot/TodoSummaryList.qml create mode 100644 framework/php/tests/snapshot/TodoSummaryReadModel.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 049b4f0..a782fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0 - **`Maker\Support\Naming`** — `camelTo($name, $separator)` helper. Replaces inline `preg_replace('/(?Dto` + `UpdateDto` under `src/Dto/` alongside the controller, and the controller dispatches via `#[MapRequestPayload]`. Closes the input-validation gap from the audit: malformed JSON, missing required fields, or `#[Assert\NotBlank]` violations now produce RFC 7807 `application/problem+json` automatically (Symfony's `RequestPayloadValueResolver`) — no more `if (isset($data['title']))` boilerplate, no silent type coercion. Update DTOs use nullable defaults so PATCH callers send only the fields they want changed. Without `--with-dto` the legacy template still ships unchanged. Maker fails loud if `symfony/validator` isn't autoloadable. Skeleton + example/todo composer.json pull `symfony/validator` so scaffolded apps work out of the box. Snapshot test exercises both modes. - **`make:bridge:event ` maker.** Generates a domain-event class (`src/Event/Event.php`, readonly value object), a subscriber (`src/EventSubscriber/Subscriber.php`) that republishes via `PublisherInterface` on `app://event/`, and a QML stub (`{qml_path}/EventHandler.qml`) that listens via `MercureClient` and re-emits as a typed `signal`. Closes the third row of PLAN.md §8's makers table; pairs with the existing `make:bridge:resource` / `command` / `window` makers so domain events have a one-command path from PHP through to QML. +- **`make:bridge:read-model ` maker.** Generates a query-only projection: `src/ReadModel/ReadModel.php` (query service stub injecting `EntityManagerInterface`), `src/Controller/Controller.php` (single GET handler at `/api/`), and `{qml_path}/List.qml` (`ReactiveListModel` bound to the route, deliberately *no* Mercure topic — read-models aren't auto-reactive; invalidation is event-driven via `make:bridge:event`). Closes the fourth row of PLAN.md §8's makers table. ### Changed diff --git a/framework/php/config/services.yaml b/framework/php/config/services.yaml index 79fe629..9a1b57e 100644 --- a/framework/php/config/services.yaml +++ b/framework/php/config/services.yaml @@ -45,3 +45,7 @@ when@dev: PhpQml\Bridge\Maker\BridgeEventMaker: arguments: $qmlPath: '%bridge.qml_path%' + + PhpQml\Bridge\Maker\BridgeReadModelMaker: + arguments: + $qmlPath: '%bridge.qml_path%' diff --git a/framework/php/src/Maker/BridgeReadModelMaker.php b/framework/php/src/Maker/BridgeReadModelMaker.php new file mode 100644 index 0000000..06f5c38 --- /dev/null +++ b/framework/php/src/Maker/BridgeReadModelMaker.php @@ -0,0 +1,143 @@ +` — generates a query-only projection. + * + * Read-models are server-side joined / aggregated views QML reads + * without going through a writable `#[BridgeResource]`. Three files: + * + * - src/ReadModel/ReadModel.php — query service stub + * - src/Controller/Controller.php — `GET /api/` + * - {qml_path}/List.qml — `ReactiveListModel` bound to the route, + * no Mercure topic (read-models aren't reactive — invalidation is + * driven by domain events; see `make:bridge:event`). + */ +final class BridgeReadModelMaker extends AbstractMaker +{ + public function __construct( + private readonly string $qmlPath = '../qml/', + ) { + } + + public static function getCommandName(): string + { + return 'make:bridge:read-model'; + } + + public static function getCommandDescription(): string + { + return 'Generate a read-only projection (query service + GET controller + QML stub).'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument( + 'name', + InputArgument::OPTIONAL, + 'CamelCase projection name (e.g. TodoSummary, RecentActivity).', + ) + ->setHelp( + "Creates three files:\n\n" + ." • src/ReadModel/ReadModel.php — query service (you fill in the body)\n" + ." • src/Controller/Controller.php — GET handler at /api/\n" + ." • {qml_path}/List.qml — ReactiveListModel bound to the route\n\n" + ."Read-models are not auto-reactive. To refresh from server-side changes,\n" + ."generate a paired domain event with make:bridge:event.\n" + ); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + NameInput::askOrFail( + $input, + $io, + 'name', + 'Projection name (CamelCase, e.g. TodoSummary)?', + 'Projection name cannot be empty.', + ); + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $rawName = (string) $input->getArgument('name'); + $singular = ucfirst(Str::asCamelCase($rawName)); + $pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular); + $resource = Naming::camelTo($singular, '_'); + $route = '/api/'.Naming::camelTo($pluralCamel, '-'); + + $readModelFqcn = $generator->createClassNameDetails( + $singular, + 'ReadModel\\', + 'ReadModel', + ); + $controllerFqcn = $generator->createClassNameDetails( + $singular, + 'Controller\\', + 'Controller', + ); + + $vars = [ + 'singular' => $singular, + 'resource' => $resource, + 'route' => $route, + 'read_model_short' => $readModelFqcn->getShortName(), + 'read_model_fqcn' => $readModelFqcn->getFullName(), + 'controller_short' => $controllerFqcn->getShortName(), + ]; + + $generator->generateFile( + 'src/ReadModel/'.$readModelFqcn->getShortName().'.php', + __DIR__.'/templates/ReadModel.tpl.php', + $vars, + ); + $generator->generateFile( + 'src/Controller/'.$controllerFqcn->getShortName().'.php', + __DIR__.'/templates/ReadModelController.tpl.php', + $vars, + ); + + $qmlTarget = rtrim($this->qmlPath, '/').'/'.$singular.'List.qml'; + $generator->generateFile( + $qmlTarget, + __DIR__.'/templates/ReadModelQml.tpl.php', + $vars, + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + $io->text([ + 'Next:', + " • Implement {$readModelFqcn->getShortName()}::query().", + " • Use {$singular}List.qml in your QML.", + ' • Optionally pair with make:bridge:event for invalidation.', + ]); + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + $dependencies->addClassDependency(EntityManagerInterface::class, 'doctrine/orm'); + $dependencies->addClassDependency(Route::class, 'symfony/routing'); + $dependencies->addClassDependency(JsonResponse::class, 'symfony/http-foundation'); + } +} diff --git a/framework/php/src/Maker/templates/ReadModel.tpl.php b/framework/php/src/Maker/templates/ReadModel.tpl.php new file mode 100644 index 0000000..34199db --- /dev/null +++ b/framework/php/src/Maker/templates/ReadModel.tpl.php @@ -0,0 +1,38 @@ + + +declare(strict_types=1); + +namespace App\ReadModel; + +use Doctrine\ORM\EntityManagerInterface; + +/** + * Auto-generated query service for the read-model. + * + * Read-models are server-side projections — joined fetches, aggregates, + * denormalised views — that QML reads without going through a writable + * `#[BridgeResource]`. Replace the body of `query()` with the actual + * DQL / raw SQL / joined fetch. + * + * Per PLAN.md §4 *Pagination*, return an array of associative arrays so + * the controller can normalise to JSON without a serializer; or wire up + * a normalizer if you prefer typed DTOs in the projection. + */ +final readonly class ReadModel + +{ + public function __construct( + private EntityManagerInterface $em, + ) { + } + + /** + * @return list> + */ + public function query(): array + { + // TODO: implement the read query — DQL, ->createQueryBuilder(), + // or ->getConnection()->executeQuery() as fits. + return []; + } +} diff --git a/framework/php/src/Maker/templates/ReadModelController.tpl.php b/framework/php/src/Maker/templates/ReadModelController.tpl.php new file mode 100644 index 0000000..aef5f26 --- /dev/null +++ b/framework/php/src/Maker/templates/ReadModelController.tpl.php @@ -0,0 +1,29 @@ + + +declare(strict_types=1); + +namespace App\Controller; + +use App\ReadModel\ReadModel; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Attribute\Route; + +/** + * Read-only endpoint for the projection. + * Auto-generated by `make:bridge:read-model` — the read-model owns + * the query; this controller just normalises the result to JSON. + */ +final class Controller + +{ + public function __construct( + private readonly ReadModel $readModel, + ) { + } + + #[Route('', name: '_read', methods: ['GET'])] + public function __invoke(): JsonResponse + { + return new JsonResponse($this->readModel->query()); + } +} diff --git a/framework/php/src/Maker/templates/ReadModelQml.tpl.php b/framework/php/src/Maker/templates/ReadModelQml.tpl.php new file mode 100644 index 0000000..41b0c72 --- /dev/null +++ b/framework/php/src/Maker/templates/ReadModelQml.tpl.php @@ -0,0 +1,28 @@ +// Auto-generated by `bin/console make:bridge:read-model `. +// Read-only projection — no Mercure topic, no auto-updates. +// +// For invalidation: when the underlying data changes, dispatch a +// domain event from PHP (see `make:bridge:event`) and call +// `List.refresh()` from the event-handler in QML. + +import QtQuick +import QtQuick.Controls +import PhpQml.Bridge + +ListView { + id: List + + model: ReactiveListModel { + baseUrl: BackendConnection.url + token: BackendConnection.token + source: "" + // topic: intentionally unset — read-models are queries, not + // reactive resources. Drive refreshes from domain events. + } + + delegate: ItemDelegate { + // Replace with your projection's columns. + text: String(model.modelData) + width: ListView.view.width + } +} diff --git a/framework/php/tests/snapshot/TodoSummaryController.php b/framework/php/tests/snapshot/TodoSummaryController.php new file mode 100644 index 0000000..06eb074 --- /dev/null +++ b/framework/php/tests/snapshot/TodoSummaryController.php @@ -0,0 +1,28 @@ +readModel->query()); + } +} diff --git a/framework/php/tests/snapshot/TodoSummaryList.qml b/framework/php/tests/snapshot/TodoSummaryList.qml new file mode 100644 index 0000000..c244ba1 --- /dev/null +++ b/framework/php/tests/snapshot/TodoSummaryList.qml @@ -0,0 +1,28 @@ +// Auto-generated by `bin/console make:bridge:read-model TodoSummary`. +// Read-only projection — no Mercure topic, no auto-updates. +// +// For invalidation: when the underlying data changes, dispatch a +// domain event from PHP (see `make:bridge:event`) and call +// `todoSummaryList.refresh()` from the event-handler in QML. + +import QtQuick +import QtQuick.Controls +import PhpQml.Bridge + +ListView { + id: todoSummaryList + + model: ReactiveListModel { + baseUrl: BackendConnection.url + token: BackendConnection.token + source: "/api/todo-summaries" + // topic: intentionally unset — read-models are queries, not + // reactive resources. Drive refreshes from domain events. + } + + delegate: ItemDelegate { + // Replace with your projection's columns. + text: String(model.modelData) + width: ListView.view.width + } +} diff --git a/framework/php/tests/snapshot/TodoSummaryReadModel.php b/framework/php/tests/snapshot/TodoSummaryReadModel.php new file mode 100644 index 0000000..e405574 --- /dev/null +++ b/framework/php/tests/snapshot/TodoSummaryReadModel.php @@ -0,0 +1,37 @@ +> + */ + public function query(): array + { + // TODO: implement the read query — DQL, ->createQueryBuilder(), + // or ->getConnection()->executeQuery() as fits. + return []; + } +} diff --git a/framework/php/tests/snapshot/run.sh b/framework/php/tests/snapshot/run.sh index c227004..d0e947a 100755 --- a/framework/php/tests/snapshot/run.sh +++ b/framework/php/tests/snapshot/run.sh @@ -51,16 +51,20 @@ clear_outputs && bin/console make:bridge:resource Todo --no-interaction >/dev/null \ && bin/console make:bridge:command MarkAllDone --no-interaction >/dev/null \ && bin/console make:bridge:window Todo --no-interaction >/dev/null \ - && bin/console make:bridge:event TodoCompleted --no-interaction >/dev/null ) + && bin/console make:bridge:event TodoCompleted --no-interaction >/dev/null \ + && bin/console make:bridge:read-model TodoSummary --no-interaction >/dev/null ) -check "$APP/symfony/src/Entity/Todo.php" "$SCRIPT_DIR/Todo.php" -check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoController.php" -check "$APP/qml/TodoList.qml" "$SCRIPT_DIR/TodoList.qml" -check "$APP/symfony/src/Controller/MarkAllDoneController.php" "$SCRIPT_DIR/MarkAllDoneController.php" -check "$APP/qml/TodoWindow.qml" "$SCRIPT_DIR/TodoWindow.qml" -check "$APP/symfony/src/Event/TodoCompletedEvent.php" "$SCRIPT_DIR/TodoCompletedEvent.php" +check "$APP/symfony/src/Entity/Todo.php" "$SCRIPT_DIR/Todo.php" +check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoController.php" +check "$APP/qml/TodoList.qml" "$SCRIPT_DIR/TodoList.qml" +check "$APP/symfony/src/Controller/MarkAllDoneController.php" "$SCRIPT_DIR/MarkAllDoneController.php" +check "$APP/qml/TodoWindow.qml" "$SCRIPT_DIR/TodoWindow.qml" +check "$APP/symfony/src/Event/TodoCompletedEvent.php" "$SCRIPT_DIR/TodoCompletedEvent.php" check "$APP/symfony/src/EventSubscriber/TodoCompletedSubscriber.php" "$SCRIPT_DIR/TodoCompletedSubscriber.php" -check "$APP/qml/TodoCompletedEventHandler.qml" "$SCRIPT_DIR/TodoCompletedEventHandler.qml" +check "$APP/qml/TodoCompletedEventHandler.qml" "$SCRIPT_DIR/TodoCompletedEventHandler.qml" +check "$APP/symfony/src/ReadModel/TodoSummaryReadModel.php" "$SCRIPT_DIR/TodoSummaryReadModel.php" +check "$APP/symfony/src/Controller/TodoSummaryController.php" "$SCRIPT_DIR/TodoSummaryController.php" +check "$APP/qml/TodoSummaryList.qml" "$SCRIPT_DIR/TodoSummaryList.qml" # ── Mode 2: --with-dto (re-runs make:bridge:resource only) ──────────── # The entity + QML output is byte-identical between modes; only the