v0.2.0 (3/N): extract Maker shared helpers (NameInput, Naming)
DRY pass identified by the post-v0.1.2 audit: every make:bridge:* maker re-implemented the same "prompt, trim, ucfirst, reject empty" closure in interact(), and the camel-case-to-separator regex was duplicated between BridgeResourceMaker (`_`-joined route plurals) and BridgeCommandMaker (`-`-joined kebab slugs). Two helpers under PhpQml\Bridge\Maker\Support: - NameInput::askOrFail() — replaces 3× inline closures - Naming::camelTo($name, $separator) — replaces 2× inline regexes All 3 makers now go through the helpers; behaviour preserved (maker snapshot test still passes — generated Todo / TodoController / TodoList / MarkAllDoneController / TodoWindow byte-identical to the v0.1.2 baselines). NamingTest covers the documented cases plus a regression case for acronyms (HTTPClient → h-t-t-p-client; the regex splits at every internal capital, which is correct for the route-slug use case). Test count 17 → 23, all passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0
|
|||||||
- **`PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`.** The bridge's three public services now ship as interfaces (same namespace as concrete, mirroring upstream `HubInterface`/`Hub`). App controllers and listeners should typehint these instead of the concrete classes so swappable implementations (offline-buffer publisher, request-stamp correlation context, etc.) remain non-breaking. Existing `Publisher` / `ModelPublisher` / `CorrelationContext` classes implement the new interfaces unchanged.
|
- **`PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`.** The bridge's three public services now ship as interfaces (same namespace as concrete, mirroring upstream `HubInterface`/`Hub`). App controllers and listeners should typehint these instead of the concrete classes so swappable implementations (offline-buffer publisher, request-stamp correlation context, etc.) remain non-breaking. Existing `Publisher` / `ModelPublisher` / `CorrelationContext` classes implement the new interfaces unchanged.
|
||||||
- **`BridgeOp` enum.** PHP 8.1 string-backed enum (`Upsert` / `Delete` / `Replace` / `Event`) replacing the raw `'upsert'`/`'delete'` strings previously passed between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. Values match PLAN.md §4's envelope `op` wire format. Typo'd ops are now caught at the type level instead of silently producing envelopes clients ignore.
|
- **`BridgeOp` enum.** PHP 8.1 string-backed enum (`Upsert` / `Delete` / `Replace` / `Event`) replacing the raw `'upsert'`/`'delete'` strings previously passed between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. Values match PLAN.md §4's envelope `op` wire format. Typo'd ops are now caught at the type level instead of silently producing envelopes clients ignore.
|
||||||
- **`BridgeBundleInfo` value object** carrying the bundle's name + class FQCN. `HealthController` now constructor-injects this instead of `PublisherInterface` as the deep-load canary, so the readiness probe is no longer coupled to the publisher's contract. `/healthz` response gains a `name` field (`php-qml/bridge`); the `bundle` field now reports `PhpQml\Bridge\BridgeBundle` (was `PhpQml\Bridge\Publisher`).
|
- **`BridgeBundleInfo` value object** carrying the bundle's name + class FQCN. `HealthController` now constructor-injects this instead of `PublisherInterface` as the deep-load canary, so the readiness probe is no longer coupled to the publisher's contract. `/healthz` response gains a `name` field (`php-qml/bridge`); the `bundle` field now reports `PhpQml\Bridge\BridgeBundle` (was `PhpQml\Bridge\Publisher`).
|
||||||
|
- **`Maker\Support\NameInput`** — shared interactive name prompt. All three `make:bridge:*` makers (`resource`, `command`, `window`) re-implemented the same "prompt, trim, ucfirst, reject empty" closure inline; collapsed into one call site so empty-argument and validation behaviour stay in lockstep.
|
||||||
|
- **`Maker\Support\Naming`** — `camelTo($name, $separator)` helper. Replaces inline `preg_replace('/(?<!^)[A-Z]/', $sep.'$0', $name)` regex copies (BridgeResourceMaker emits `_`-joined route plurals, BridgeCommandMaker emits `-`-joined kebab slugs).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace PhpQml\Bridge\Maker;
|
namespace PhpQml\Bridge\Maker;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PhpQml\Bridge\Maker\Support\NameInput;
|
||||||
|
use PhpQml\Bridge\Maker\Support\Naming;
|
||||||
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||||
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
||||||
use Symfony\Bundle\MakerBundle\Generator;
|
use Symfony\Bundle\MakerBundle\Generator;
|
||||||
@@ -61,23 +63,20 @@ final class BridgeCommandMaker extends AbstractMaker
|
|||||||
|
|
||||||
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
||||||
{
|
{
|
||||||
if (null === $input->getArgument('name')) {
|
NameInput::askOrFail(
|
||||||
$name = $io->ask('Command name (CamelCase)?', null, static function (?string $v): string {
|
$input,
|
||||||
if (null === $v || '' === trim($v)) {
|
$io,
|
||||||
throw new \RuntimeException('Command name cannot be empty.');
|
'name',
|
||||||
}
|
'Command name (CamelCase)?',
|
||||||
|
'Command name cannot be empty.',
|
||||||
return ucfirst(trim($v));
|
);
|
||||||
});
|
|
||||||
$input->setArgument('name', $name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
||||||
{
|
{
|
||||||
$rawName = (string) $input->getArgument('name');
|
$rawName = (string) $input->getArgument('name');
|
||||||
$singular = ucfirst(Str::asCamelCase($rawName));
|
$singular = ucfirst(Str::asCamelCase($rawName));
|
||||||
$kebab = strtolower(preg_replace('/(?<!^)[A-Z]/', '-$0', $singular) ?? $singular);
|
$kebab = Naming::camelTo($singular, '-');
|
||||||
$route = '/api/'.$kebab;
|
$route = '/api/'.$kebab;
|
||||||
|
|
||||||
$controllerFqcn = $generator->createClassNameDetails(
|
$controllerFqcn = $generator->createClassNameDetails(
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ namespace PhpQml\Bridge\Maker;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use PhpQml\Bridge\Attribute\BridgeResource;
|
use PhpQml\Bridge\Attribute\BridgeResource;
|
||||||
|
use PhpQml\Bridge\Maker\Support\NameInput;
|
||||||
|
use PhpQml\Bridge\Maker\Support\Naming;
|
||||||
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||||
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
||||||
use Symfony\Bundle\MakerBundle\Generator;
|
use Symfony\Bundle\MakerBundle\Generator;
|
||||||
@@ -80,16 +82,13 @@ final class BridgeResourceMaker extends AbstractMaker
|
|||||||
|
|
||||||
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
||||||
{
|
{
|
||||||
if (null === $input->getArgument('name')) {
|
NameInput::askOrFail(
|
||||||
$name = $io->ask('What is the resource name (e.g. Todo)?', null, static function (?string $v): string {
|
$input,
|
||||||
if (null === $v || '' === trim($v)) {
|
$io,
|
||||||
throw new \RuntimeException('Resource name cannot be empty.');
|
'name',
|
||||||
}
|
'What is the resource name (e.g. Todo)?',
|
||||||
|
'Resource name cannot be empty.',
|
||||||
return ucfirst(trim($v));
|
);
|
||||||
});
|
|
||||||
$input->setArgument('name', $name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
||||||
@@ -100,7 +99,7 @@ final class BridgeResourceMaker extends AbstractMaker
|
|||||||
$singular = ucfirst(Str::asCamelCase($rawName));
|
$singular = ucfirst(Str::asCamelCase($rawName));
|
||||||
$pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular);
|
$pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular);
|
||||||
$resource = strtolower($singular);
|
$resource = strtolower($singular);
|
||||||
$pluralUnder = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $pluralCamel) ?? $pluralCamel);
|
$pluralUnder = Naming::camelTo($pluralCamel, '_');
|
||||||
$route = '/api/'.$pluralUnder;
|
$route = '/api/'.$pluralUnder;
|
||||||
|
|
||||||
$entityFqcn = $generator->createClassNameDetails(
|
$entityFqcn = $generator->createClassNameDetails(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace PhpQml\Bridge\Maker;
|
namespace PhpQml\Bridge\Maker;
|
||||||
|
|
||||||
|
use PhpQml\Bridge\Maker\Support\NameInput;
|
||||||
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||||
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
||||||
use Symfony\Bundle\MakerBundle\Generator;
|
use Symfony\Bundle\MakerBundle\Generator;
|
||||||
@@ -57,16 +58,13 @@ final class BridgeWindowMaker extends AbstractMaker
|
|||||||
|
|
||||||
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
||||||
{
|
{
|
||||||
if (null === $input->getArgument('name')) {
|
NameInput::askOrFail(
|
||||||
$name = $io->ask('Window name?', null, static function (?string $v): string {
|
$input,
|
||||||
if (null === $v || '' === trim($v)) {
|
$io,
|
||||||
throw new \RuntimeException('Window name cannot be empty.');
|
'name',
|
||||||
}
|
'Window name?',
|
||||||
|
'Window name cannot be empty.',
|
||||||
return ucfirst(trim($v));
|
);
|
||||||
});
|
|
||||||
$input->setArgument('name', $name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
||||||
|
|||||||
48
framework/php/src/Maker/Support/NameInput.php
Normal file
48
framework/php/src/Maker/Support/NameInput.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Maker\Support;
|
||||||
|
|
||||||
|
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared interactive name prompt for the bridge makers.
|
||||||
|
*
|
||||||
|
* Every `make:bridge:*` maker takes a single CamelCase `name` argument
|
||||||
|
* and re-implemented the same "prompt, trim, ucfirst, reject empty"
|
||||||
|
* closure inline. This collapses that into one call site so the empty-
|
||||||
|
* argument and validation behaviour stay in lockstep across makers.
|
||||||
|
*/
|
||||||
|
final class NameInput
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Fill the named argument from an interactive prompt if it isn't set.
|
||||||
|
*
|
||||||
|
* The closure-based validator throws `\RuntimeException` on empty input,
|
||||||
|
* which Maker-bundle's `ConsoleStyle::ask()` interprets as "render
|
||||||
|
* error, re-prompt" rather than aborting — same behaviour as the
|
||||||
|
* inline closures it replaces.
|
||||||
|
*/
|
||||||
|
public static function askOrFail(
|
||||||
|
InputInterface $input,
|
||||||
|
ConsoleStyle $io,
|
||||||
|
string $argument,
|
||||||
|
string $question,
|
||||||
|
string $errorMessage = 'Name cannot be empty.',
|
||||||
|
): void {
|
||||||
|
if (null !== $input->getArgument($argument)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $io->ask($question, null, static function (?string $v) use ($errorMessage): string {
|
||||||
|
if (null === $v || '' === trim($v)) {
|
||||||
|
throw new \RuntimeException($errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ucfirst(trim($v));
|
||||||
|
});
|
||||||
|
$input->setArgument($argument, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
framework/php/src/Maker/Support/Naming.php
Normal file
27
framework/php/src/Maker/Support/Naming.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Maker\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CamelCase → separator-joined lowercase converter shared between makers.
|
||||||
|
*
|
||||||
|
* `BridgeResourceMaker` needs `_` (route plural → table-style); `BridgeCommandMaker`
|
||||||
|
* needs `-` (kebab route slug). Same regex, two separators — collapsed here so
|
||||||
|
* the regex lives in one place.
|
||||||
|
*/
|
||||||
|
final class Naming
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Convert a CamelCase identifier to a separator-joined lowercase string.
|
||||||
|
*
|
||||||
|
* camelTo('TodoList', '_') === 'todo_list'
|
||||||
|
* camelTo('MarkAllDone', '-') === 'mark-all-done'
|
||||||
|
* camelTo('Todo', '-') === 'todo'
|
||||||
|
*/
|
||||||
|
public static function camelTo(string $name, string $separator): string
|
||||||
|
{
|
||||||
|
return strtolower(preg_replace('/(?<!^)[A-Z]/', $separator.'$0', $name) ?? $name);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
framework/php/tests/Maker/Support/NamingTest.php
Normal file
31
framework/php/tests/Maker/Support/NamingTest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Tests\Maker\Support;
|
||||||
|
|
||||||
|
use PhpQml\Bridge\Maker\Support\Naming;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(Naming::class)]
|
||||||
|
final class NamingTest extends TestCase
|
||||||
|
{
|
||||||
|
#[DataProvider('camelToCases')]
|
||||||
|
public function testCamelTo(string $input, string $separator, string $expected): void
|
||||||
|
{
|
||||||
|
self::assertSame($expected, Naming::camelTo($input, $separator));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return iterable<string, array{string, string, string}> */
|
||||||
|
public static function camelToCases(): iterable
|
||||||
|
{
|
||||||
|
yield 'single word, underscore' => ['Todo', '_', 'todo'];
|
||||||
|
yield 'two words, underscore' => ['TodoList', '_', 'todo_list'];
|
||||||
|
yield 'two words, dash' => ['MarkAllDone', '-', 'mark-all-done'];
|
||||||
|
yield 'leading uppercase, no split' => ['Todo', '-', 'todo'];
|
||||||
|
yield 'all caps stay together (acronym preserved)' => ['HTTPClient', '-', 'h-t-t-p-client'];
|
||||||
|
yield 'empty input' => ['', '-', ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user