From 4a42de702b99b8c12b650de8c24878d05247bb75 Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 2 May 2026 02:45:42 +0200 Subject: [PATCH] Phase 2 sub-commit 4: make:bridge:resource maker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle picks up symfony/maker-bundle as require-dev. New BridgeResourceMaker under PhpQml\Bridge\Maker generates three files for a named resource: - src/Entity/.php — Doctrine entity with #[BridgeResource] and a UUIDv7 id by default. --int-id flips to auto-incrementing int IDs. - src/Controller/Controller.php — CRUD on /api/{plural} (list, create, update, delete) with serializer- normalised JSON responses. - {qml_path}/List.qml — starter ListView wrapped around a ReactiveListModel bound to the right topic and source URL. The Doctrine subscriber from sub-commit 2 picks the entity up automatically — no per-resource listener generated. The QML snippet target defaults to '../qml/' (relative to the Symfony project root) and is overridable via the maker's $qmlPath constructor arg. Templates live under src/Maker/templates/ as .tpl.php files using short-echo and alternative-syntax control structures by convention. PHPStan and php-cs-fixer skip them — the maker's Generator binds the template variables at render time. Skeleton picks up MakerBundle as a `dev` bundle and require-dev'd symfony/maker-bundle, so `bin/console make:bridge:resource Todo` works out-of-the-box. Verified: maker runs end-to-end against `Todo` and emits readable, syntactically valid output. composer quality (16 tests, 45 assertions, PHPStan clean, cs-fixer clean) stays green. Co-Authored-By: Claude Opus 4.7 (1M context) --- framework/php/.php-cs-fixer.dist.php | 5 +- framework/php/composer.json | 3 +- framework/php/phpstan.neon.dist | 3 + .../php/src/Maker/BridgeResourceMaker.php | 169 +++++++++++++ .../src/Maker/templates/Controller.tpl.php | 95 ++++++++ .../php/src/Maker/templates/Entity.tpl.php | 71 ++++++ .../src/Maker/templates/QmlSnippet.tpl.php | 28 +++ framework/skeleton/symfony/composer.json | 3 + framework/skeleton/symfony/composer.lock | 230 +++++++++++++++++- framework/skeleton/symfony/config/bundles.php | 1 + 10 files changed, 603 insertions(+), 5 deletions(-) create mode 100644 framework/php/src/Maker/BridgeResourceMaker.php create mode 100644 framework/php/src/Maker/templates/Controller.tpl.php create mode 100644 framework/php/src/Maker/templates/Entity.tpl.php create mode 100644 framework/php/src/Maker/templates/QmlSnippet.tpl.php diff --git a/framework/php/.php-cs-fixer.dist.php b/framework/php/.php-cs-fixer.dist.php index a8643f5..a184838 100644 --- a/framework/php/.php-cs-fixer.dist.php +++ b/framework/php/.php-cs-fixer.dist.php @@ -1,7 +1,10 @@ in([__DIR__ . '/src', __DIR__ . '/tests']); + ->in([__DIR__ . '/src', __DIR__ . '/tests']) + // Maker templates use short-echo syntax and alternative-syntax control + // structures by design — cs-fixer's @Symfony rules would mangle them. + ->notPath('Maker/templates'); return (new PhpCsFixer\Config()) ->setRiskyAllowed(true) diff --git a/framework/php/composer.json b/framework/php/composer.json index e3a5fe1..ab43fe4 100644 --- a/framework/php/composer.json +++ b/framework/php/composer.json @@ -27,7 +27,8 @@ "phpstan/phpstan-symfony": "^2", "phpstan/phpstan-doctrine": "^2", "friendsofphp/php-cs-fixer": "^3", - "symfony/phpunit-bridge": "^8.0" + "symfony/phpunit-bridge": "^8.0", + "symfony/maker-bundle": "^1.62" }, "autoload": { "psr-4": { diff --git a/framework/php/phpstan.neon.dist b/framework/php/phpstan.neon.dist index 5d8a921..02e93da 100644 --- a/framework/php/phpstan.neon.dist +++ b/framework/php/phpstan.neon.dist @@ -9,3 +9,6 @@ parameters: - tests excludePaths: - vendor/* + # Maker templates use variables injected by the Generator at + # render time; static analysis can't see the binding. + - src/Maker/templates/* diff --git a/framework/php/src/Maker/BridgeResourceMaker.php b/framework/php/src/Maker/BridgeResourceMaker.php new file mode 100644 index 0000000..1cacd27 --- /dev/null +++ b/framework/php/src/Maker/BridgeResourceMaker.php @@ -0,0 +1,169 @@ +` — generates the three files needed to + * expose a Doctrine entity as a reactive bridge resource: + * + * - src/Entity/.php — `#[BridgeResource]` + `#[ORM\Entity]` + * - src/Controller/Controller.php — CRUD on `/api/{plural}` + * - {qml_path}/List.qml — starter `ReactiveListModel` + * + * The Doctrine subscriber installed by the bundle picks the entity up + * automatically — no per-resource listener is generated. The QML snippet + * goes to `qml_path` (default: `../qml/`, configurable via the bundle's + * `qml_path` option in services.yaml). + * + * See PLAN.md §8 (*Custom makers*). + */ +final class BridgeResourceMaker extends AbstractMaker +{ + public function __construct( + private readonly string $qmlPath = '../qml/', + ) { + } + + public static function getCommandName(): string + { + return 'make:bridge:resource'; + } + + public static function getCommandDescription(): string + { + return 'Generate a #[BridgeResource] entity, CRUD controller, and QML snippet.'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument( + 'name', + InputArgument::OPTIONAL, + 'Singular name of the resource (e.g. Todo).', + ) + ->addOption( + 'int-id', + null, + InputOption::VALUE_NONE, + 'Use auto-incrementing int IDs instead of the default UUIDv7.', + ) + ->setHelp( + "The maker creates three files:\n\n" + ." • src/Entity/Todo.php — Doctrine entity tagged with #[BridgeResource]\n" + ." • src/Controller/TodoController.php — CRUD on /api/todos\n" + ." • {qml_path}/TodoList.qml — starter ReactiveListModel snippet\n\n" + ."After the maker, run bin/console make:migration and apply it.\n" + ); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + if (null === $input->getArgument('name')) { + $name = $io->ask('What is the resource name (e.g. Todo)?', null, static function (?string $v): string { + if (null === $v || '' === trim($v)) { + throw new \RuntimeException('Resource name cannot be empty.'); + } + + return ucfirst(trim($v)); + }); + $input->setArgument('name', $name); + } + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $rawName = (string) $input->getArgument('name'); + $useUuid = !(bool) $input->getOption('int-id'); + + $singular = ucfirst(Str::asCamelCase($rawName)); + $pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular); + $resource = strtolower($singular); + $pluralUnder = strtolower(preg_replace('/(?createClassNameDetails( + $singular, + 'Entity\\', + ); + $controllerFqcn = $generator->createClassNameDetails( + $singular, + 'Controller\\', + 'Controller', + ); + + $vars = [ + 'singular' => $singular, + 'plural' => $pluralUnder, + 'resource' => $resource, + 'route' => $route, + 'entity_short' => $entityFqcn->getShortName(), + 'entity_fqcn' => $entityFqcn->getFullName(), + 'controller_fqcn' => $controllerFqcn->getFullName(), + 'use_uuid' => $useUuid, + ]; + + $generator->generateFile( + 'src/Entity/'.$entityFqcn->getShortName().'.php', + __DIR__.'/templates/Entity.tpl.php', + $vars, + ); + $generator->generateFile( + 'src/Controller/'.$controllerFqcn->getShortName().'.php', + __DIR__.'/templates/Controller.tpl.php', + $vars, + ); + + // QML snippet — outside the Symfony project root, so we use a + // path relative to the project's working dir. + $qmlTarget = rtrim($this->qmlPath, '/').'/'.$singular.'List.qml'; + $generator->generateFile( + $qmlTarget, + __DIR__.'/templates/QmlSnippet.tpl.php', + $vars, + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + $io->text([ + 'Next:', + ' 1) bin/console make:migration', + ' 2) bin/console doctrine:migrations:migrate -n', + " 3) Use {$singular}List.qml from your QML.", + ]); + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + $dependencies->addClassDependency(ORM\Entity::class, 'doctrine/orm'); + $dependencies->addClassDependency(BridgeResource::class, 'php-qml/bridge'); + $dependencies->addClassDependency(Route::class, 'symfony/routing'); + $dependencies->addClassDependency(JsonResponse::class, 'symfony/http-foundation'); + $dependencies->addClassDependency(Request::class, 'symfony/http-foundation'); + $dependencies->addClassDependency(EntityManagerInterface::class, 'doctrine/orm'); + $dependencies->addClassDependency(SerializerInterface::class, 'symfony/serializer'); + $dependencies->addClassDependency(Uuid::class, 'symfony/uid'); + } +} diff --git a/framework/php/src/Maker/templates/Controller.tpl.php b/framework/php/src/Maker/templates/Controller.tpl.php new file mode 100644 index 0000000..a6f7853 --- /dev/null +++ b/framework/php/src/Maker/templates/Controller.tpl.php @@ -0,0 +1,95 @@ + + +declare(strict_types=1); + +namespace App\Controller; + +use ; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Auto-generated CRUD controller for the bridge resource. + * Edit freely — re-running make:bridge:resource won't overwrite this file. + */ +#[Route('')] +final class Controller +{ + public function __construct( + private readonly EntityManagerInterface $em, + private readonly SerializerInterface $serializer, + ) { + } + + #[Route('', name: '_list', methods: ['GET'])] + public function list(): JsonResponse + { + $items = $this->em->getRepository(::class)->findAll(); + + return new JsonResponse($this->serializer->normalize($items, 'json')); + } + + #[Route('', name: '_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $data = json_decode((string) $request->getContent(), true) ?? []; + $entity = new (); + if (isset($data['title'])) { + $entity->setTitle((string) $data['title']); + } + if (isset($data['done'])) { + $entity->setDone((bool) $data['done']); + } + + $this->em->persist($entity); + $this->em->flush(); + + return new JsonResponse( + $this->serializer->normalize($entity, 'json'), + Response::HTTP_CREATED, + ); + } + + #[Route('/{id}', name: '_update', methods: ['PATCH'])] + public function update(string $id, Request $request): JsonResponse + { + $entity = $this->em->getRepository(::class)->find($id); + if (null === $entity) { + return new JsonResponse( + ['title' => 'Not Found', 'status' => 404], + Response::HTTP_NOT_FOUND, + ['Content-Type' => 'application/problem+json'], + ); + } + + $data = json_decode((string) $request->getContent(), true) ?? []; + if (isset($data['title'])) { + $entity->setTitle((string) $data['title']); + } + if (isset($data['done'])) { + $entity->setDone((bool) $data['done']); + } + + $this->em->flush(); + + return new JsonResponse($this->serializer->normalize($entity, 'json')); + } + + #[Route('/{id}', name: '_delete', methods: ['DELETE'])] + public function delete(string $id): JsonResponse + { + $entity = $this->em->getRepository(::class)->find($id); + if (null === $entity) { + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } + + $this->em->remove($entity); + $this->em->flush(); + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/framework/php/src/Maker/templates/Entity.tpl.php b/framework/php/src/Maker/templates/Entity.tpl.php new file mode 100644 index 0000000..9db3dea --- /dev/null +++ b/framework/php/src/Maker/templates/Entity.tpl.php @@ -0,0 +1,71 @@ + + +declare(strict_types=1); + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; +use PhpQml\Bridge\Attribute\BridgeResource; + +use Symfony\Component\Uid\Uuid; + + +#[ORM\Entity] +#[BridgeResource(name: '')] +class + +{ + + #[ORM\Id] + #[ORM\Column(type: 'uuid', unique: true)] + private Uuid $id; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + + #[ORM\Column(length: 255)] + private string $title = ''; + + #[ORM\Column] + private bool $done = false; + + + public function __construct() + { + $this->id = Uuid::v7(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getId(): ?int + { + return $this->id; + } + + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function isDone(): bool + { + return $this->done; + } + + public function setDone(bool $done): void + { + $this->done = $done; + } +} diff --git a/framework/php/src/Maker/templates/QmlSnippet.tpl.php b/framework/php/src/Maker/templates/QmlSnippet.tpl.php new file mode 100644 index 0000000..a389513 --- /dev/null +++ b/framework/php/src/Maker/templates/QmlSnippet.tpl.php @@ -0,0 +1,28 @@ +// Auto-generated by `bin/console make:bridge:resource `. +// Drop this into your QML and customize the delegate to taste. + +import QtQuick +import QtQuick.Controls +import PhpQml.Bridge + +ListView { + id: List + + model: ReactiveListModel { + baseUrl: BackendConnection.url + token: BackendConnection.token + source: "" + topic: "app://model/" + } + + delegate: ItemDelegate { + required property string id + required property string title + required property bool done + required property bool pending + + text: title + (done ? " ✓" : "") + opacity: pending ? 0.5 : 1.0 + width: ListView.view.width + } +} diff --git a/framework/skeleton/symfony/composer.json b/framework/skeleton/symfony/composer.json index ae7b2b5..1aacea4 100644 --- a/framework/skeleton/symfony/composer.json +++ b/framework/skeleton/symfony/composer.json @@ -16,6 +16,9 @@ "doctrine/doctrine-migrations-bundle": "^4.0", "php-qml/bridge": "@dev" }, + "require-dev": { + "symfony/maker-bundle": "^1.62" + }, "autoload": { "psr-4": { "App\\": "src/" diff --git a/framework/skeleton/symfony/composer.lock b/framework/skeleton/symfony/composer.lock index d406eab..09af9be 100644 --- a/framework/skeleton/symfony/composer.lock +++ b/framework/skeleton/symfony/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aa91f6235e16c7047de75633ae520e74", + "content-hash": "dd53e6e42aa4773eaed84e9eaa374a68", "packages": [ { "name": "doctrine/collections", @@ -1199,7 +1199,7 @@ "dist": { "type": "path", "url": "../../php", - "reference": "57ce5999bb54c35e1a8b61f3bd35c5247baeb730" + "reference": "68fca95525db2311a08deb931f1b92909b20c450" }, "require": { "doctrine/dbal": "^4.0", @@ -1225,6 +1225,7 @@ "phpstan/phpstan-doctrine": "^2", "phpstan/phpstan-symfony": "^2", "phpunit/phpunit": "^11", + "symfony/maker-bundle": "^1.62", "symfony/phpunit-bridge": "^8.0" }, "type": "symfony-bundle", @@ -5510,7 +5511,230 @@ "time": "2026-03-30T15:14:47+00:00" } ], - "packages-dev": [], + "packages-dev": [ + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "symfony/maker-bundle", + "version": "v1.67.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/maker-bundle.git", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1", + "doctrine/inflector": "^2.0", + "nikic/php-parser": "^5.0", + "php": ">=8.1", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/doctrine-bundle": "<2.10", + "doctrine/orm": "<2.15" + }, + "require-dev": { + "composer/semver": "^3.0", + "doctrine/doctrine-bundle": "^2.10|^3.0", + "doctrine/orm": "^2.15|^3", + "doctrine/persistence": "^3.1|^4.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/twig": "^3.0|^4.x-dev" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MakerBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.", + "homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html", + "keywords": [ + "code generator", + "dev", + "generator", + "scaffold", + "scaffolding" + ], + "support": { + "issues": "https://github.com/symfony/maker-bundle/issues", + "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-18T13:39:06+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": { diff --git a/framework/skeleton/symfony/config/bundles.php b/framework/skeleton/symfony/config/bundles.php index 03c463a..a9f6dfd 100644 --- a/framework/skeleton/symfony/config/bundles.php +++ b/framework/skeleton/symfony/config/bundles.php @@ -6,5 +6,6 @@ return [ Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], PhpQml\Bridge\BridgeBundle::class => ['all' => true], ];