From cbe18eddaf5c844b9484332d1a16f49b90dd80c0 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 27 May 2026 12:19:59 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20harden=200.7.0=20release=20=E2=80=94=20r?= =?UTF-8?q?epair=20lint=20config,=20test=20fixes,=20dep=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-flight hardening for the 0.7.0 release. Three test failures and a broken lint config were uncovered while auditing changes since 0.6.1. - phpcs.xml: drop stale demo/app and demo/modules file entries - tests/SplitWorkflowTest: rename SPLIT_TOKEN -> MARKO_BUILD_PAT (#84) - database-mysql: hoist test helpers into Connection/Helpers.php loaded via autoload-dev.files, so factory test doesn't depend on which sibling file pest happens to load first - CHANGELOG: drop stale [Unreleased] block (release.sh regenerates) - composer: refresh lock to pick up new admin-panel-latte/twig path repos - phpcbf: auto-fix ~360 pre-existing violations across 114 files (trailing commas, multiline signatures, blank lines — cosmetic only) All 5616 tests pass; phpcs clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 7 - composer.json | 5 +- .../tests/ResolverIntegrationTest.php | 47 ++- .../tests/TemplateMigrationTest.php | 4 +- .../tests/TemplateExistenceTest.php | 114 +++--- .../Controller/DashboardControllerTest.php | 4 +- .../tests/Unit/EngineSiblingCleanupTest.php | 4 +- .../Unit/Exceptions/NoDriverExceptionTest.php | 25 +- .../amphp/src/Command/PubSubListenCommand.php | 5 +- .../tests/KnownDriversValidationTest.php | 9 +- .../tests/Unit/Guard/SessionGuardTest.php | 30 +- .../tests/KnownDriversValidationTest.php | 42 +- packages/core/src/Application.php | 1 - packages/core/src/Commands/ListCommand.php | 5 +- .../core/src/Exceptions/ModuleException.php | 4 +- .../PluginInterceptionIntegrationTest.php | 20 +- packages/core/tests/Unit/ApplicationTest.php | 6 +- .../Module/GlobalMiddlewareResolverTest.php | 4 +- .../Unit/Plugin/PluginInterceptorTest.php | 167 ++++++-- .../src/Query/MySqlQueryBuilder.php | 3 +- .../tests/Connection/Helpers.php | 99 +++++ .../tests/Connection/MySqlConnectionTest.php | 91 ---- .../tests/Module/ModuleBindingsTest.php | 2 +- .../tests/Query/MySqlJsonQueryBuilderTest.php | 104 ++--- .../Query/MySqlQueryBuilderAggregatesTest.php | 54 +-- .../Query/MySqlQueryBuilderGroupByTest.php | 78 ++-- .../tests/Query/MySqlQueryBuilderTest.php | 276 ++++++------- .../src/Query/PgSqlQueryBuilder.php | 2 +- .../Fixtures/Variant/VariantQueryBuilder.php | 39 +- .../Fixtures/Variant/VariantSqlGenerator.php | 21 +- .../tests/Module/DialectOverrideTest.php | 49 +-- .../tests/Query/PgSqlJsonQueryBuilderTest.php | 6 +- .../Query/PgSqlQueryBuilderGroupByTest.php | 10 +- .../tests/Query/PgSqlQueryBuilderTest.php | 8 +- .../tests/Module/ModuleBootTest.php | 51 +-- .../database/src/Entity/EntityCollection.php | 5 +- .../src/Entity/EntityCompanionStorage.php | 5 +- .../database/src/Entity/EntityHydrator.php | 5 +- .../src/Entity/RelationshipLoader.php | 22 +- .../src/Repository/RepositoryQueryBuilder.php | 10 +- .../database/src/Schema/SchemaRegistry.php | 5 +- .../Attributes/RelationshipAttributeTest.php | 7 +- .../tests/Entity/EntityCollectionTest.php | 4 +- .../tests/Entity/EntityHydratorTest.php | 91 ++-- .../EntityMetadataFactoryRelationshipTest.php | 49 ++- packages/database/tests/Entity/EntityTest.php | 29 +- .../RelationshipLoaderBelongsToManyTest.php | 20 +- .../Entity/RelationshipLoaderNestedTest.php | 10 +- .../tests/Entity/RelationshipLoaderTest.php | 20 +- .../Entity/RelationshipValidationTest.php | 10 +- .../TableExtensionIntegrationTest.php | 205 +++++----- .../tests/KnownDriversValidationTest.php | 9 +- .../tests/Query/QueryBuilderInterfaceTest.php | 24 +- .../tests/Query/QuerySpecificationTest.php | 20 +- .../Query/SpecEagerLoadCompositionTest.php | 387 ++++++++++++------ .../Repository/RepositoryBatchInsertTest.php | 142 ++++--- .../RepositoryLifecycleEventTest.php | 30 +- .../Repository/RepositoryMatchingTest.php | 10 +- .../RepositoryQueryBuilderEnhancedTest.php | 28 +- .../Repository/RepositoryReturnTypeTest.php | 10 +- .../tests/Repository/RepositoryTest.php | 221 +++++----- .../tests/Repository/RepositoryWithTest.php | 40 +- .../tests/Repository/StringPrimaryKeyTest.php | 131 +++++- .../tests/Schema/SchemaRegistryTest.php | 63 +-- .../src/Exceptions/DevServerException.php | 5 +- .../tests/KnownDriversValidationTest.php | 9 +- .../tests/Unit/UrlLinkificationTest.php | 23 +- .../tests/KnownDriversValidationTest.php | 9 +- .../tests/KnownDriversValidationTest.php | 9 +- .../framework/tests/ArchitectureDocTest.php | 28 +- .../tests/PackagistPublishingTest.php | 23 +- .../framework/tests/RootComposerJsonTest.php | 147 ++++--- .../tests/Unit/FilesystemHealthCheckTest.php | 64 ++- .../http/tests/KnownDriversValidationTest.php | 10 +- .../tests/KnownDriversValidationTest.php | 9 +- packages/layout/src/ComponentCollection.php | 11 +- packages/layout/src/ComponentCollector.php | 10 +- .../src/ComponentCollectorInterface.php | 5 +- packages/layout/src/ComponentDataResolver.php | 11 +- .../src/DiscoveringComponentCollector.php | 5 +- .../AmbiguousSortOrderException.php | 6 +- .../src/Exceptions/CircularSlotException.php | 5 +- .../DuplicateComponentException.php | 6 +- .../src/Exceptions/SlotNotFoundException.php | 5 +- packages/layout/src/HandleResolver.php | 11 +- packages/layout/src/LayoutProcessor.php | 5 +- packages/layout/src/LayoutResolver.php | 5 +- .../AmbiguousSortOrderExceptionTest.php | 19 +- .../Exceptions/CircularSlotExceptionTest.php | 19 +- .../ComponentNotFoundExceptionTest.php | 19 +- .../DuplicateComponentExceptionTest.php | 19 +- .../LayoutNotFoundExceptionTest.php | 19 +- .../Exceptions/SlotNotFoundExceptionTest.php | 19 +- .../tests/Unit/ComponentCollectionTest.php | 23 +- .../tests/Unit/ComponentCollectorTest.php | 6 +- packages/layout/tests/Unit/Helpers.php | 5 +- .../tests/Unit/LayoutProcessorNestedTest.php | 70 +++- .../layout/tests/Unit/LayoutProcessorTest.php | 20 +- .../Unit/Middleware/LayoutMiddlewareTest.php | 6 +- .../Unit/Exceptions/NoDriverExceptionTest.php | 4 +- .../tests/KnownDriversValidationTest.php | 9 +- .../tests/KnownDriversValidationTest.php | 10 +- .../Unit/Exceptions/NoDriverExceptionTest.php | 15 +- .../src/Driver/PgSqlPublisher.php | 5 +- .../tests/Driver/PgSqlPublisherTest.php | 10 +- .../tests/Driver/PgSqlSubscriberTest.php | 15 +- .../tests/Driver/PgSqlSubscriptionTest.php | 5 +- .../tests/PgSqlPubSubConnectionTest.php | 10 +- .../src/Driver/RedisPublisher.php | 5 +- .../tests/Driver/RedisPublisherTest.php | 5 +- .../tests/Driver/RedisSubscriberTest.php | 5 +- .../tests/RedisPubSubConnectionTest.php | 5 +- .../pubsub/src/Exceptions/PubSubException.php | 15 +- packages/pubsub/src/PublisherInterface.php | 5 +- .../tests/KnownDriversValidationTest.php | 10 +- packages/pubsub/tests/MessageTest.php | 37 +- .../pubsub/tests/SubscriberInterfaceTest.php | 80 ++-- .../tests/KnownDriversValidationTest.php | 27 +- .../routing/src/RouteMatcherInterface.php | 5 +- .../tests/KnownDriversValidationTest.php | 9 +- .../Unit/Middleware/SessionMiddlewareTest.php | 10 +- .../tests/KnownDriversSuggestParityTest.php | 8 +- packages/sse/src/SseStream.php | 5 +- packages/sse/tests/SseStreamTest.php | 2 + .../KnownDrivers/KnownDriversValidator.php | 7 +- .../AssertionFailedExceptionTest.php | 27 +- .../Exceptions/NoDriverExceptionTest.php | 4 +- .../tests/KnownDriversValidationTest.php | 10 +- .../src/Extensions/SlotExtension.php | 1 + .../view-latte/tests/LatteViewConfigTest.php | 2 +- packages/view-twig/src/ModuleLoader.php | 11 +- packages/view-twig/src/TwigView.php | 11 +- .../view-twig/tests/TwigEngineFactoryTest.php | 3 +- .../Exceptions/NoDriverExceptionTest.php | 4 +- .../Feature/CrossEngineTemplateParityTest.php | 38 +- .../view/tests/KnownDriversValidationTest.php | 9 +- packages/view/tests/PackageStructureTest.php | 1 - .../Jobs/DispatchWebhookJobRetryTest.php | 62 ++- .../tests/Jobs/DispatchWebhookJobTest.php | 31 +- phpcs.xml | 2 - tests/SplitWorkflowTest.php | 6 +- 141 files changed, 2648 insertions(+), 1570 deletions(-) create mode 100644 packages/database-mysql/tests/Connection/Helpers.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c442f1a6..94baa91e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,13 +34,6 @@ Entries from `0.4.0` onward are generated automatically by `bin/release.sh` from * @michalbiarda made their first contribution in https://github.com/marko-php/marko/pull/58 -## [Unreleased] - -### New Features -* feat: add marko/page-cache and marko/page-cache-file packages -* feat(database): add `selectRaw`, `whereRaw`, and `orderByRaw` to `QueryBuilderInterface` with positional bindings and denylist validation -* feat(core): add module-declared global middleware support via globalMiddleware key in module.php - ## [0.5.0] - 2026-05-01 ### New Features diff --git a/composer.json b/composer.json index c8ef0677..e710b53d 100644 --- a/composer.json +++ b/composer.json @@ -548,6 +548,9 @@ "Marko\\View\\Twig\\Tests\\": "packages/view-twig/tests/", "Marko\\Vite\\Tests\\": "packages/vite/tests/", "Marko\\Webhook\\Tests\\": "packages/webhook/tests/" - } + }, + "files": [ + "packages/database-mysql/tests/Connection/Helpers.php" + ] } } diff --git a/packages/admin-panel-latte/tests/ResolverIntegrationTest.php b/packages/admin-panel-latte/tests/ResolverIntegrationTest.php index 2b2c9cf0..1afb7d5a 100644 --- a/packages/admin-panel-latte/tests/ResolverIntegrationTest.php +++ b/packages/admin-panel-latte/tests/ResolverIntegrationTest.php @@ -8,25 +8,28 @@ use Marko\View\ModuleTemplateResolver; use Marko\View\ViewConfig; -it('ModuleTemplateResolver resolves admin-panel::dashboard/index to the new admin-panel-latte path', function (): void { - $adminPanelLatteDir = dirname(__DIR__); - - $modules = [ - new ModuleManifest( - name: 'marko/admin-panel-latte', - version: '1.0.0', - path: $adminPanelLatteDir, - source: 'vendor', - extra: ['marko' => ['templates_for' => 'marko/admin-panel']], - ), - ]; - - $resolver = new ModuleTemplateResolver( - new ModuleRepository($modules), - new ViewConfig(new FakeConfigRepository(['view.extension' => '.latte'])), - ); - - $result = $resolver->resolve('admin-panel::dashboard/index'); - - expect($result)->toBe($adminPanelLatteDir . '/resources/views/dashboard/index.latte'); -}); +it( + 'ModuleTemplateResolver resolves admin-panel::dashboard/index to the new admin-panel-latte path', + function (): void { + $adminPanelLatteDir = dirname(__DIR__); + + $modules = [ + new ModuleManifest( + name: 'marko/admin-panel-latte', + version: '1.0.0', + path: $adminPanelLatteDir, + source: 'vendor', + extra: ['marko' => ['templates_for' => 'marko/admin-panel']], + ), + ]; + + $resolver = new ModuleTemplateResolver( + new ModuleRepository($modules), + new ViewConfig(new FakeConfigRepository(['view.extension' => '.latte'])), + ); + + $result = $resolver->resolve('admin-panel::dashboard/index'); + + expect($result)->toBe($adminPanelLatteDir . '/resources/views/dashboard/index.latte'); + } +); diff --git a/packages/admin-panel-latte/tests/TemplateMigrationTest.php b/packages/admin-panel-latte/tests/TemplateMigrationTest.php index cfc465ca..f8f8d715 100644 --- a/packages/admin-panel-latte/tests/TemplateMigrationTest.php +++ b/packages/admin-panel-latte/tests/TemplateMigrationTest.php @@ -30,7 +30,9 @@ it('LayoutTemplateTest.php has been removed from packages/admin-panel/tests/Unit/Template/', function (): void { $oldTestPath = dirname(__DIR__, 2) . '/admin-panel/tests/Unit/Template/LayoutTemplateTest.php'; - expect(file_exists($oldTestPath))->toBeFalse('LayoutTemplateTest.php should not exist in admin-panel/tests/Unit/Template/'); + expect(file_exists($oldTestPath))->toBeFalse( + 'LayoutTemplateTest.php should not exist in admin-panel/tests/Unit/Template/' + ); }); it('the moved Pest file uses dirname(__DIR__) for $viewsPath (one level up)', function (): void { diff --git a/packages/admin-panel-twig/tests/TemplateExistenceTest.php b/packages/admin-panel-twig/tests/TemplateExistenceTest.php index 11f48d52..4c841acc 100644 --- a/packages/admin-panel-twig/tests/TemplateExistenceTest.php +++ b/packages/admin-panel-twig/tests/TemplateExistenceTest.php @@ -5,43 +5,52 @@ describe('admin-panel-twig templates', function (): void { $viewsDir = dirname(__DIR__) . '/resources/views'; - test('auth/login.twig exists and renders a form with email and password fields', function () use ($viewsDir): void { - $path = $viewsDir . '/auth/login.twig'; - - expect(file_exists($path))->toBeTrue(); - - $contents = file_get_contents($path); - - expect($contents) - ->toContain('and($contents)->toContain('name="email"') - ->and($contents)->toContain('name="password"'); - }); - - test('layout/base.twig exists and contains HTML doctype, sidebar include, and content block', function () use ($viewsDir): void { - $path = $viewsDir . '/layout/base.twig'; - - expect(file_exists($path))->toBeTrue(); - - $contents = file_get_contents($path); - - expect($contents) - ->toContain('') - ->and($contents)->toContain("{% include 'admin-panel::partials/sidebar'") - ->and($contents)->toContain('{% block content %}'); - }); - - test('dashboard/index.twig exists and extends layout/base.twig with a content block', function () use ($viewsDir): void { - $path = $viewsDir . '/dashboard/index.twig'; - - expect(file_exists($path))->toBeTrue(); - - $contents = file_get_contents($path); - - expect($contents) - ->toContain("{% extends 'admin-panel::layout/base' %}") - ->and($contents)->toContain('{% block content %}'); - }); + test( + 'auth/login.twig exists and renders a form with email and password fields', + function () use ($viewsDir): void { + $path = $viewsDir . '/auth/login.twig'; + + expect(file_exists($path))->toBeTrue(); + + $contents = file_get_contents($path); + + expect($contents) + ->toContain('and($contents)->toContain('name="email"') + ->and($contents)->toContain('name="password"'); + } + ); + + test( + 'layout/base.twig exists and contains HTML doctype, sidebar include, and content block', + function () use ($viewsDir): void { + $path = $viewsDir . '/layout/base.twig'; + + expect(file_exists($path))->toBeTrue(); + + $contents = file_get_contents($path); + + expect($contents) + ->toContain('') + ->and($contents)->toContain("{% include 'admin-panel::partials/sidebar'") + ->and($contents)->toContain('{% block content %}'); + } + ); + + test( + 'dashboard/index.twig exists and extends layout/base.twig with a content block', + function () use ($viewsDir): void { + $path = $viewsDir . '/dashboard/index.twig'; + + expect(file_exists($path))->toBeTrue(); + + $contents = file_get_contents($path); + + expect($contents) + ->toContain("{% extends 'admin-panel::layout/base' %}") + ->and($contents)->toContain('{% block content %}'); + } + ); test('partials/sidebar.twig exists and iterates menu items', function () use ($viewsDir): void { $path = $viewsDir . '/partials/sidebar.twig'; @@ -81,18 +90,21 @@ ->and($contents)->toContain('{{ csrfToken }}'); }); - test('the layout\'s content block can be overridden by child templates (verified via dashboard.twig)', function () use ($viewsDir): void { - $layoutPath = $viewsDir . '/layout/base.twig'; - $dashboardPath = $viewsDir . '/dashboard/index.twig'; - - expect(file_exists($layoutPath))->toBeTrue() - ->and(file_exists($dashboardPath))->toBeTrue(); - - $layoutContents = file_get_contents($layoutPath); - $dashboardContents = file_get_contents($dashboardPath); - - expect($layoutContents)->toContain('{% block content %}') - ->and($dashboardContents)->toContain('{% block content %}') - ->and($dashboardContents)->toContain('{% endblock %}'); - }); + test( + 'the layout\'s content block can be overridden by child templates (verified via dashboard.twig)', + function () use ($viewsDir): void { + $layoutPath = $viewsDir . '/layout/base.twig'; + $dashboardPath = $viewsDir . '/dashboard/index.twig'; + + expect(file_exists($layoutPath))->toBeTrue() + ->and(file_exists($dashboardPath))->toBeTrue(); + + $layoutContents = file_get_contents($layoutPath); + $dashboardContents = file_get_contents($dashboardPath); + + expect($layoutContents)->toContain('{% block content %}') + ->and($dashboardContents)->toContain('{% block content %}') + ->and($dashboardContents)->toContain('{% endblock %}'); + } + ); }); diff --git a/packages/admin-panel/tests/Unit/Controller/DashboardControllerTest.php b/packages/admin-panel/tests/Unit/Controller/DashboardControllerTest.php index 83f148f5..83b7ba1c 100644 --- a/packages/admin-panel/tests/Unit/Controller/DashboardControllerTest.php +++ b/packages/admin-panel/tests/Unit/Controller/DashboardControllerTest.php @@ -106,7 +106,9 @@ public function getMenuItems(): array } it('requires authentication via AdminAuthMiddleware for dashboard', function (): void { - $middlewareAttributes = (new ReflectionMethod(DashboardController::class, 'index'))->getAttributes(Middleware::class); + $middlewareAttributes = (new ReflectionMethod(DashboardController::class, 'index'))->getAttributes( + Middleware::class + ); expect($middlewareAttributes)->toHaveCount(1) ->and($middlewareAttributes[0]->newInstance()->middleware)->toContain(AdminAuthMiddleware::class); diff --git a/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php b/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php index 2c22614c..d24b6ad3 100644 --- a/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php +++ b/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php @@ -5,7 +5,9 @@ it('packages/admin-panel/resources/views/ no longer exists', function (): void { $viewsDir = dirname(__DIR__, 2) . '/resources/views'; - expect(is_dir($viewsDir))->toBeFalse('resources/views/ directory should not exist after engine sibling extraction'); + expect(is_dir($viewsDir))->toBeFalse( + 'resources/views/ directory should not exist after engine sibling extraction' + ); }); it('packages/admin-panel/composer.json includes a suggest block', function (): void { diff --git a/packages/admin/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/admin/tests/Unit/Exceptions/NoDriverExceptionTest.php index bc6b5286..932e77f5 100644 --- a/packages/admin/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/admin/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -6,17 +6,20 @@ use Marko\Admin\Exceptions\NoDriverException; describe('NoDriverException', function (): void { - it('has DRIVER_PACKAGES constant listing marko/admin-api, marko/admin-auth, and marko/admin-panel', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constants = $reflection->getConstants(); - - expect($constants)->toHaveKey('DRIVER_PACKAGES') - ->and($constants['DRIVER_PACKAGES'])->toBe([ - 'marko/admin-api', - 'marko/admin-auth', - 'marko/admin-panel', - ]); - }); + it( + 'has DRIVER_PACKAGES constant listing marko/admin-api, marko/admin-auth, and marko/admin-panel', + function (): void { + $reflection = new ReflectionClass(NoDriverException::class); + $constants = $reflection->getConstants(); + + expect($constants)->toHaveKey('DRIVER_PACKAGES') + ->and($constants['DRIVER_PACKAGES'])->toBe([ + 'marko/admin-api', + 'marko/admin-auth', + 'marko/admin-panel', + ]); + } + ); it('provides suggestion with composer require commands for all driver packages', function (): void { $exception = NoDriverException::noDriverInstalled(); diff --git a/packages/amphp/src/Command/PubSubListenCommand.php b/packages/amphp/src/Command/PubSubListenCommand.php index ef77aa34..84f60554 100644 --- a/packages/amphp/src/Command/PubSubListenCommand.php +++ b/packages/amphp/src/Command/PubSubListenCommand.php @@ -18,7 +18,10 @@ public function __construct( private EventLoopRunner $runner, ) {} - public function execute(Input $input, Output $output): int + public function execute( + Input $input, + Output $output, + ): int { $output->writeLine('Starting pub/sub listener...'); $output->writeLine('Press Ctrl+C to stop.'); diff --git a/packages/authentication/tests/KnownDriversValidationTest.php b/packages/authentication/tests/KnownDriversValidationTest.php index 605b631c..9a837a6f 100644 --- a/packages/authentication/tests/KnownDriversValidationTest.php +++ b/packages/authentication/tests/KnownDriversValidationTest.php @@ -7,9 +7,12 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all authentication drivers', function () use ($knownDriversPath, $skeletonComposerPath) { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); -}); +test( + 'skeleton suggest block contains all authentication drivers', + function () use ($knownDriversPath, $skeletonComposerPath) { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); + } +); test('every authentication driver follows marko slash prefix pattern', function () use ($knownDriversPath) { KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); diff --git a/packages/authentication/tests/Unit/Guard/SessionGuardTest.php b/packages/authentication/tests/Unit/Guard/SessionGuardTest.php index bcbf6316..5870ccd1 100644 --- a/packages/authentication/tests/Unit/Guard/SessionGuardTest.php +++ b/packages/authentication/tests/Unit/Guard/SessionGuardTest.php @@ -316,17 +316,26 @@ public function retrieveByCredentials(array $credentials): ?AuthenticatableInter return null; } - public function validateCredentials(AuthenticatableInterface $user, array $credentials): bool + public function validateCredentials( + AuthenticatableInterface $user, + array $credentials, + ): bool { return false; } - public function retrieveByRememberToken(int|string $identifier, string $token): ?AuthenticatableInterface + public function retrieveByRememberToken( + int|string $identifier, + string $token, + ): ?AuthenticatableInterface { return $this->userByRememberToken; } - public function updateRememberToken(AuthenticatableInterface $user, ?string $token): void + public function updateRememberToken( + AuthenticatableInterface $user, + ?string $token, + ): void { $user->setRememberToken($token); } @@ -416,17 +425,26 @@ public function retrieveByCredentials(array $credentials): ?AuthenticatableInter return null; } - public function validateCredentials(AuthenticatableInterface $user, array $credentials): bool + public function validateCredentials( + AuthenticatableInterface $user, + array $credentials, + ): bool { return false; } - public function retrieveByRememberToken(int|string $identifier, string $token): ?AuthenticatableInterface + public function retrieveByRememberToken( + int|string $identifier, + string $token, + ): ?AuthenticatableInterface { return $this->userByRememberToken; } - public function updateRememberToken(AuthenticatableInterface $user, ?string $token): void + public function updateRememberToken( + AuthenticatableInterface $user, + ?string $token, + ): void { $user->setRememberToken($token); } diff --git a/packages/cache/tests/KnownDriversValidationTest.php b/packages/cache/tests/KnownDriversValidationTest.php index 178d3bcb..dbe97400 100644 --- a/packages/cache/tests/KnownDriversValidationTest.php +++ b/packages/cache/tests/KnownDriversValidationTest.php @@ -8,27 +8,33 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all cache drivers', function () use ($knownDriversPath, $skeletonComposerPath): void { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); -}); +test( + 'skeleton suggest block contains all cache drivers', + function () use ($knownDriversPath, $skeletonComposerPath): void { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); + } +); test('every cache driver follows marko slash prefix pattern', function () use ($knownDriversPath): void { KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); }); -test('validation test skips skeleton parity assertion when skeleton is absent', function () use ($knownDriversPath): void { - $nonExistentPath = __DIR__ . '/non-existent-path/composer.json'; - - expect(file_exists($nonExistentPath))->toBeFalse(); - - $skipped = false; - - try { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $nonExistentPath); - } catch (SkippedWithMessageException $e) { - $skipped = true; - expect($e->getMessage())->toContain('not found'); +test( + 'validation test skips skeleton parity assertion when skeleton is absent', + function () use ($knownDriversPath): void { + $nonExistentPath = __DIR__ . '/non-existent-path/composer.json'; + + expect(file_exists($nonExistentPath))->toBeFalse(); + + $skipped = false; + + try { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $nonExistentPath); + } catch (SkippedWithMessageException $e) { + $skipped = true; + expect($e->getMessage())->toContain('not found'); + } + + expect($skipped)->toBeTrue(); } - - expect($skipped)->toBeTrue(); -}); +); diff --git a/packages/core/src/Application.php b/packages/core/src/Application.php index 2765db69..af42de32 100644 --- a/packages/core/src/Application.php +++ b/packages/core/src/Application.php @@ -304,7 +304,6 @@ private function discoverCommands(): void $this->commandRunner = new CommandRunner($this->container, $this->commandRegistry); } - /** * @throws RouteException|RouteConflictException|ReflectionException */ diff --git a/packages/core/src/Commands/ListCommand.php b/packages/core/src/Commands/ListCommand.php index a3853259..0289b5b1 100644 --- a/packages/core/src/Commands/ListCommand.php +++ b/packages/core/src/Commands/ListCommand.php @@ -34,7 +34,10 @@ public function execute( $displayNames = []; foreach ($commands as $definition) { if ($definition->aliases !== []) { - $displayNames[$definition->name] = $definition->name . ' (' . implode(', ', $definition->aliases) . ')'; + $displayNames[$definition->name] = $definition->name . ' (' . implode( + ', ', + $definition->aliases + ) . ')'; } else { $displayNames[$definition->name] = $definition->name; } diff --git a/packages/core/src/Exceptions/ModuleException.php b/packages/core/src/Exceptions/ModuleException.php index 0cec9159..3d28efc8 100644 --- a/packages/core/src/Exceptions/ModuleException.php +++ b/packages/core/src/Exceptions/ModuleException.php @@ -4,6 +4,8 @@ namespace Marko\Core\Exceptions; +use Marko\Routing\Middleware\MiddlewareInterface; + class ModuleException extends MarkoException { public static function invalidManifest( @@ -36,7 +38,7 @@ public static function invalidMiddlewareClass( return new self( message: "Invalid globalMiddleware entry in module '$moduleName': $reason", context: "While resolving global middleware for '$moduleName'", - suggestion: "Ensure '$className' exists and implements " . \Marko\Routing\Middleware\MiddlewareInterface::class, + suggestion: "Ensure '$className' exists and implements " . MiddlewareInterface::class, ); } diff --git a/packages/core/tests/Feature/Plugin/PluginInterceptionIntegrationTest.php b/packages/core/tests/Feature/Plugin/PluginInterceptionIntegrationTest.php index b81b7d5c..118fe6d4 100644 --- a/packages/core/tests/Feature/Plugin/PluginInterceptionIntegrationTest.php +++ b/packages/core/tests/Feature/Plugin/PluginInterceptionIntegrationTest.php @@ -46,7 +46,10 @@ class PIIT_HasherAfterPlugin public static array $log = []; /** @noinspection PhpUnused - Invoked via plugin interception */ - public function hash(mixed $result, string $value): string + public function hash( + mixed $result, + string $value, + ): string { self::$log[] = "after:$result"; @@ -67,7 +70,10 @@ public function hash(string $value): null } /** @noinspection PhpUnused - Invoked via plugin interception */ - public function hashAfter(mixed $result, string $value): string + public function hashAfter( + mixed $result, + string $value, + ): string { self::$log[] = "after:$result"; @@ -98,7 +104,10 @@ class PIIT_FirstAfterPlugin public static array $log = []; /** @noinspection PhpUnused - Invoked via plugin interception */ - public function hash(mixed $result, string $value): string + public function hash( + mixed $result, + string $value, + ): string { self::$log[] = "first-after:$result"; @@ -111,7 +120,10 @@ class PIIT_SecondAfterPlugin public static array $log = []; /** @noinspection PhpUnused - Invoked via plugin interception */ - public function hash(mixed $result, string $value): string + public function hash( + mixed $result, + string $value, + ): string { self::$log[] = "second-after:$result"; diff --git a/packages/core/tests/Unit/ApplicationTest.php b/packages/core/tests/Unit/ApplicationTest.php index 4dc0ea3e..f567c2c3 100644 --- a/packages/core/tests/Unit/ApplicationTest.php +++ b/packages/core/tests/Unit/ApplicationTest.php @@ -1694,7 +1694,10 @@ public function execute( $app = new Application(); expect(fn () => $app->handleRequest()) - ->toThrow(RuntimeException::class, 'Cannot handle HTTP requests: marko/routing is not installed. Run: composer require marko/routing'); + ->toThrow( + RuntimeException::class, + 'Cannot handle HTTP requests: marko/routing is not installed. Run: composer require marko/routing' + ); }); it('creates a request from globals and routes it through the router', function (): void { @@ -2004,4 +2007,3 @@ public function run(string \$result): string appTestCleanupDirectory($baseDir); }); - diff --git a/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php b/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php index c815d452..24d63dd6 100644 --- a/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php +++ b/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php @@ -23,9 +23,9 @@ function makeMiddlewareClass(string $className): void eval( "namespace $namespace; " . - "class $shortName implements \\" . MiddlewareInterface::class . " { " . + "class $shortName implements \\" . MiddlewareInterface::class . ' { ' . "public function handle(\Marko\Routing\Http\Request \$request, callable \$next): \Marko\Routing\Http\Response { return \$next(\$request); } " . - "}" + '}' ); } diff --git a/packages/core/tests/Unit/Plugin/PluginInterceptorTest.php b/packages/core/tests/Unit/Plugin/PluginInterceptorTest.php index 43d2b31d..df749604 100644 --- a/packages/core/tests/Unit/Plugin/PluginInterceptorTest.php +++ b/packages/core/tests/Unit/Plugin/PluginInterceptorTest.php @@ -50,7 +50,10 @@ class PIT_GreeterAfterPlugin { public static array $callLog = []; - public function greet(mixed $result, string $name): mixed + public function greet( + mixed $result, + string $name, + ): mixed { self::$callLog[] = 'PIT_GreeterAfterPlugin::greet'; @@ -130,7 +133,10 @@ class PIT_ArgsService { public static array $callLog = []; - public function process(string $name, int $count): string + public function process( + string $name, + int $count, + ): string { self::$callLog[] = "PIT_ArgsService::process($name, $count)"; @@ -142,7 +148,10 @@ class PIT_ArgsBeforePlugin { public static array $receivedArgs = []; - public function process(string $name, int $count): ?string + public function process( + string $name, + int $count, + ): ?string { self::$receivedArgs = ['name' => $name, 'count' => $count]; PIT_ArgsService::$callLog[] = "PIT_ArgsBeforePlugin::process($name, $count)"; @@ -153,7 +162,10 @@ public function process(string $name, int $count): ?string class PIT_ArgsModifyingBeforePlugin { - public function process(string $name, int $count): ?array + public function process( + string $name, + int $count, + ): ?array { return ['modified-name', $count + 10]; } @@ -163,7 +175,11 @@ class PIT_ArgsAfterPlugin { public static array $receivedArgs = []; - public function process(mixed $result, string $name, int $count): mixed + public function process( + mixed $result, + string $name, + int $count, + ): mixed { self::$receivedArgs = ['result' => $result, 'name' => $name, 'count' => $count]; @@ -245,7 +261,10 @@ public function getValue(mixed $result): int class PIT_RequiredParamService { - public function create(string $name, int $age): string + public function create( + string $name, + int $age, + ): string { return "$name is $age"; } @@ -253,7 +272,10 @@ public function create(string $name, int $age): string class PIT_WrongCountBeforePlugin { - public function create(string $name, int $age): ?array + public function create( + string $name, + int $age, + ): ?array { return ['only-one']; } @@ -265,7 +287,10 @@ public function create(string $name, int $age): ?array class PIT_ChainArgService { - public function transform(string $text, int $mult): string + public function transform( + string $text, + int $mult, + ): string { return "result: $text x$mult"; } @@ -273,7 +298,10 @@ public function transform(string $text, int $mult): string class PIT_ChainArgFirstPlugin { - public function transform(string $text, int $mult): ?array + public function transform( + string $text, + int $mult, + ): ?array { return ["$text-first", $mult + 1]; } @@ -281,7 +309,10 @@ public function transform(string $text, int $mult): ?array class PIT_ChainArgSecondPlugin { - public function transform(string $text, int $mult): ?array + public function transform( + string $text, + int $mult, + ): ?array { return ["$text-second", $mult + 1]; } @@ -315,7 +346,10 @@ public function process(string $input): ?string class PIT_CompleteFlowAfterPlugin { - public function process(mixed $result, string $input): string + public function process( + mixed $result, + string $input, + ): string { PIT_CompleteFlowService::$callLog[] = "PIT_CompleteFlowAfterPlugin::process($result, $input)"; @@ -550,7 +584,11 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): )); $interceptor = makePluginInterceptor($container, $registry); - $proxy = $interceptor->createProxy(PIT_GreeterInterface::class, PIT_ConcreteGreeter::class, new PIT_ConcreteGreeter()); + $proxy = $interceptor->createProxy( + PIT_GreeterInterface::class, + PIT_ConcreteGreeter::class, + new PIT_ConcreteGreeter() + ); expect($proxy)->toBeInstanceOf(PIT_GreeterInterface::class); }); @@ -612,7 +650,11 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): )); $interceptor = makePluginInterceptor($container, $registry); - $proxy = $interceptor->createProxy(PIT_ShortCircuitService::class, PIT_ShortCircuitService::class, new PIT_ShortCircuitService()); + $proxy = $interceptor->createProxy( + PIT_ShortCircuitService::class, + PIT_ShortCircuitService::class, + new PIT_ShortCircuitService() + ); $result = $proxy->fetch('mykey'); @@ -631,7 +673,11 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): )); $interceptor = makePluginInterceptor($container, $registry); - $proxy = $interceptor->createProxy(PIT_ShortCircuitService::class, PIT_ShortCircuitService::class, new PIT_ShortCircuitService()); + $proxy = $interceptor->createProxy( + PIT_ShortCircuitService::class, + PIT_ShortCircuitService::class, + new PIT_ShortCircuitService() + ); $result = $proxy->fetch('mykey'); @@ -668,7 +714,11 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): )); $interceptor = makePluginInterceptor($container, $registry); - $proxy = $interceptor->createProxy(PIT_RequiredParamService::class, PIT_RequiredParamService::class, new PIT_RequiredParamService()); + $proxy = $interceptor->createProxy( + PIT_RequiredParamService::class, + PIT_RequiredParamService::class, + new PIT_RequiredParamService() + ); expect(fn () => $proxy->create('Alice', 30))->toThrow(PluginArgumentCountException::class); }); @@ -690,7 +740,11 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): )); $interceptor = makePluginInterceptor($container, $registry); - $proxy = $interceptor->createProxy(PIT_ChainArgService::class, PIT_ChainArgService::class, new PIT_ChainArgService()); + $proxy = $interceptor->createProxy( + PIT_ChainArgService::class, + PIT_ChainArgService::class, + new PIT_ChainArgService() + ); $result = $proxy->transform('hello', 1); @@ -738,7 +792,9 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy->process('test', 42); - expect(PIT_ArgsAfterPlugin::$receivedArgs)->toBe(['result' => 'processed: test, 42', 'name' => 'test', 'count' => 42]); + expect(PIT_ArgsAfterPlugin::$receivedArgs)->toBe( + ['result' => 'processed: test, 42', 'name' => 'test', 'count' => 42] + ); }); it('chains modified results through multiple after plugins', function (): void { @@ -758,7 +814,11 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): )); $interceptor = makePluginInterceptor($container, $registry); - $proxy = $interceptor->createProxy(PIT_ResultModService::class, PIT_ResultModService::class, new PIT_ResultModService()); + $proxy = $interceptor->createProxy( + PIT_ResultModService::class, + PIT_ResultModService::class, + new PIT_ResultModService() + ); $result = $proxy->getValue(); @@ -812,7 +872,11 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): )); $interceptor = makePluginInterceptor($container, $registry); - $proxy = $interceptor->createProxy(PIT_CompleteFlowService::class, PIT_CompleteFlowService::class, new PIT_CompleteFlowService()); + $proxy = $interceptor->createProxy( + PIT_CompleteFlowService::class, + PIT_CompleteFlowService::class, + new PIT_CompleteFlowService() + ); $result = $proxy->process('test'); @@ -879,7 +943,11 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): )); $interceptor = makePluginInterceptor($container, $registry); - $proxy = $interceptor->createProxy(PIT_MethodNameService::class, PIT_MethodNameService::class, new PIT_MethodNameService()); + $proxy = $interceptor->createProxy( + PIT_MethodNameService::class, + PIT_MethodNameService::class, + new PIT_MethodNameService() + ); $result = $proxy->save('hello'); @@ -899,7 +967,11 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): )); $interceptor = makePluginInterceptor($container, $registry); - $proxy = $interceptor->createProxy(PIT_MethodNameService::class, PIT_MethodNameService::class, new PIT_MethodNameService()); + $proxy = $interceptor->createProxy( + PIT_MethodNameService::class, + PIT_MethodNameService::class, + new PIT_MethodNameService() + ); $result = $proxy->save('test data'); @@ -907,25 +979,32 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): ->and($result)->toBe('saved: test data'); }); -it('finds plugins when interface is resolved to concrete class (originalId differs from resolvedId)', function (): void { - $container = new Container(); - $registry = new PluginRegistry(); - - $registry->register(new PluginDefinition( - pluginClass: PIT_HasherPlugin::class, - targetClass: PIT_HasherInterface::class, - beforeMethods: ['hash' => ['pluginMethod' => 'hash', 'sortOrder' => 10]], - )); - - $interceptor = makePluginInterceptor($container, $registry); - $proxy = $interceptor->createProxy(PIT_HasherInterface::class, PIT_BcryptHasher::class, new PIT_BcryptHasher()); - - $result = $proxy->hash('secret'); - - expect(PIT_HasherPlugin::$callLog)->toBe(['PIT_HasherPlugin::hash(secret)']) - ->and(PIT_BcryptHasher::$callLog)->toBe(['PIT_BcryptHasher::hash(secret)']) - ->and($result)->toBe('bcrypt:secret'); -}); +it( + 'finds plugins when interface is resolved to concrete class (originalId differs from resolvedId)', + function (): void { + $container = new Container(); + $registry = new PluginRegistry(); + + $registry->register(new PluginDefinition( + pluginClass: PIT_HasherPlugin::class, + targetClass: PIT_HasherInterface::class, + beforeMethods: ['hash' => ['pluginMethod' => 'hash', 'sortOrder' => 10]], + )); + + $interceptor = makePluginInterceptor($container, $registry); + $proxy = $interceptor->createProxy( + PIT_HasherInterface::class, + PIT_BcryptHasher::class, + new PIT_BcryptHasher() + ); + + $result = $proxy->hash('secret'); + + expect(PIT_HasherPlugin::$callLog)->toBe(['PIT_HasherPlugin::hash(secret)']) + ->and(PIT_BcryptHasher::$callLog)->toBe(['PIT_BcryptHasher::hash(secret)']) + ->and($result)->toBe('bcrypt:secret'); + } +); it('exposes original target via getPluginTarget', function (): void { $container = new Container(); @@ -957,7 +1036,13 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $interceptor = makePluginInterceptor($container, $registry); - expect(fn () => $interceptor->createProxy(PIT_ReadonlyService::class, PIT_ReadonlyService::class, new PIT_ReadonlyService())) + expect( + fn () => $interceptor->createProxy( + PIT_ReadonlyService::class, + PIT_ReadonlyService::class, + new PIT_ReadonlyService() + ) + ) ->toThrow(PluginException::class); }); diff --git a/packages/database-mysql/src/Query/MySqlQueryBuilder.php b/packages/database-mysql/src/Query/MySqlQueryBuilder.php index 229303f2..02133515 100644 --- a/packages/database-mysql/src/Query/MySqlQueryBuilder.php +++ b/packages/database-mysql/src/Query/MySqlQueryBuilder.php @@ -300,7 +300,7 @@ public function groupBy( foreach ($columns as $column) { if (!IdentifierValidator::isValidIdentifier($column) && !preg_match( '/^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/', - $column + $column, )) { throw InvalidColumnException::invalidColumn($column); } @@ -954,5 +954,4 @@ private function buildLimitOffsetClause(): string return $sql; } - } diff --git a/packages/database-mysql/tests/Connection/Helpers.php b/packages/database-mysql/tests/Connection/Helpers.php new file mode 100644 index 00000000..eae07a80 --- /dev/null +++ b/packages/database-mysql/tests/Connection/Helpers.php @@ -0,0 +1,99 @@ + 'mysql', + 'host' => $host, + 'port' => $port, + 'database' => $database, + 'username' => $username, + 'password' => $password, + ]; + + if ($sslCa !== null) { + $configArray['ssl_ca'] = $sslCa; + } + + if ($sslVerifyServerCert) { + $configArray['ssl_verify_server_cert'] = true; + } + + if ($sslCert !== null) { + $configArray['ssl_cert'] = $sslCert; + } + + if ($sslKey !== null) { + $configArray['ssl_key'] = $sslKey; + } + + file_put_contents( + $tempDir . '/config/database.php', + ' + */ +function connectAndCapturePdoOptions(DatabaseConfig $config): array +{ + $capturedOptions = []; + + $connection = new class ($config, $capturedOptions) extends MySqlConnection + { + public function __construct( + DatabaseConfig $config, + private array &$capturedOptions, + ) { + parent::__construct($config); + } + + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $this->capturedOptions = $options; + + return new PDO('sqlite::memory:'); + } + }; + + $connection->connect(); + + return $capturedOptions; +} diff --git a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php index fc300557..ff55792f 100644 --- a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php +++ b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php @@ -4,7 +4,6 @@ namespace Marko\Database\MySql\Tests\Connection; -use Marko\Core\Path\ProjectPaths; use Marko\Database\Config\DatabaseConfig; use Marko\Database\Connection\ConnectionInterface; use Marko\Database\Connection\StatementInterface; @@ -15,96 +14,6 @@ use PDO; use RuntimeException; -function createTestDatabaseConfig( - string $host = 'localhost', - int $port = 3306, - string $database = 'test', - string $username = 'root', - string $password = '', - ?string $sslCa = null, - bool $sslVerifyServerCert = false, - ?string $sslCert = null, - ?string $sslKey = null, -): DatabaseConfig { - $tempDir = sys_get_temp_dir() . '/marko_mysql_test_' . bin2hex(random_bytes(8)); - mkdir($tempDir . '/config', recursive: true); - - $configArray = [ - 'driver' => 'mysql', - 'host' => $host, - 'port' => $port, - 'database' => $database, - 'username' => $username, - 'password' => $password, - ]; - - if ($sslCa !== null) { - $configArray['ssl_ca'] = $sslCa; - } - - if ($sslVerifyServerCert) { - $configArray['ssl_verify_server_cert'] = true; - } - - if ($sslCert !== null) { - $configArray['ssl_cert'] = $sslCert; - } - - if ($sslKey !== null) { - $configArray['ssl_key'] = $sslKey; - } - - file_put_contents( - $tempDir . '/config/database.php', - ' - */ -function connectAndCapturePdoOptions(DatabaseConfig $config): array -{ - $capturedOptions = []; - - $connection = new class ($config, $capturedOptions) extends MySqlConnection - { - public function __construct( - DatabaseConfig $config, - private array &$capturedOptions, - ) { - parent::__construct($config); - } - - protected function createPdo( - string $dsn, - string $username, - string $password, - array $options, - ): PDO { - $this->capturedOptions = $options; - - return new PDO('sqlite::memory:'); - } - }; - - $connection->connect(); - - return $capturedOptions; -} - describe('MySqlConnection', function (): void { it('implements ConnectionInterface', function (): void { $config = createTestDatabaseConfig(); diff --git a/packages/database-mysql/tests/Module/ModuleBindingsTest.php b/packages/database-mysql/tests/Module/ModuleBindingsTest.php index c066ba3e..4321e7d5 100644 --- a/packages/database-mysql/tests/Module/ModuleBindingsTest.php +++ b/packages/database-mysql/tests/Module/ModuleBindingsTest.php @@ -52,7 +52,7 @@ expect($moduleConfig['bindings'])->toHaveKey(QueryBuilderFactoryInterface::class) ->and($moduleConfig['bindings'][QueryBuilderFactoryInterface::class])->toBe( - MySqlQueryBuilderFactory::class + MySqlQueryBuilderFactory::class, ); }); diff --git a/packages/database-mysql/tests/Query/MySqlJsonQueryBuilderTest.php b/packages/database-mysql/tests/Query/MySqlJsonQueryBuilderTest.php index db0c1349..a5d585c1 100644 --- a/packages/database-mysql/tests/Query/MySqlJsonQueryBuilderTest.php +++ b/packages/database-mysql/tests/Query/MySqlJsonQueryBuilderTest.php @@ -35,7 +35,10 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { $this->lastQuerySql = $sql; $this->lastQueryBindings = $bindings; @@ -43,7 +46,10 @@ public function query(string $sql, array $bindings = []): array return $this->queryReturn; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { return 0; } @@ -111,22 +117,25 @@ public function lastInsertId(): int ->and($result)->toHaveCount(1); }); - it('returns rows whose JSON object contains a nested value via whereJsonContains() with a path', function (): void { - $connection = new MySqlMockConnection( - queryReturn: [['id' => 2]], - ); - $builder = new MySqlQueryBuilder($connection); - - $result = $builder - ->table('users') - ->whereJsonContains('data->roles', 'admin') - ->get(); - - expect($connection->lastQuerySql) - ->toContain("JSON_CONTAINS(JSON_EXTRACT(`data`, '$.roles'), ?)") - ->and($connection->lastQueryBindings[0])->toBe('"admin"') - ->and($result)->toHaveCount(1); - }); + it( + 'returns rows whose JSON object contains a nested value via whereJsonContains() with a path', + function (): void { + $connection = new MySqlMockConnection( + queryReturn: [['id' => 2]], + ); + $builder = new MySqlQueryBuilder($connection); + + $result = $builder + ->table('users') + ->whereJsonContains('data->roles', 'admin') + ->get(); + + expect($connection->lastQuerySql) + ->toContain("JSON_CONTAINS(JSON_EXTRACT(`data`, '$.roles'), ?)") + ->and($connection->lastQueryBindings[0])->toBe('"admin"') + ->and($result)->toHaveCount(1); + } + ); it('returns rows where a JSON path exists via whereJsonExists()', function (): void { $connection = new MySqlMockConnection( @@ -176,39 +185,42 @@ public function lastInsertId(): int ->and($connection->lastQueryBindings)->toBe([$maliciousValue]); }); - it('emits correct MySQL SQL for every JSON operator (JSON_EXTRACT / JSON_UNQUOTE / JSON_CONTAINS / JSON_CONTAINS_PATH)', function (): void { - $connection = new MySqlMockConnection(); - $builder = new MySqlQueryBuilder($connection); - - // JSON_EXTRACT via -> in WHERE + it( + 'emits correct MySQL SQL for every JSON operator (JSON_EXTRACT / JSON_UNQUOTE / JSON_CONTAINS / JSON_CONTAINS_PATH)', + function (): void { + $connection = new MySqlMockConnection(); + $builder = new MySqlQueryBuilder($connection); + + // JSON_EXTRACT via -> in WHERE $builder->table('users')->where('data->user->name', '=', 'Bob')->get(); - expect($connection->lastQuerySql) - ->toContain("JSON_EXTRACT(`data`, '$.user.name')"); - - // JSON_UNQUOTE via ->> in WHERE + expect($connection->lastQuerySql) + ->toContain("JSON_EXTRACT(`data`, '$.user.name')"); + + // JSON_UNQUOTE via ->> in WHERE $builder2 = new MySqlQueryBuilder($connection); - $builder2->table('users')->where('data->>user', '=', 'Bob')->get(); - expect($connection->lastQuerySql) - ->toContain("JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.user'))"); - - // JSON_CONTAINS on plain column + $builder2->table('users')->where('data->>user', '=', 'Bob')->get(); + expect($connection->lastQuerySql) + ->toContain("JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.user'))"); + + // JSON_CONTAINS on plain column $builder3 = new MySqlQueryBuilder($connection); - $builder3->table('users')->whereJsonContains('tags', 'premium')->get(); - expect($connection->lastQuerySql) - ->toContain('JSON_CONTAINS(`tags`, ?)'); - - // JSON_CONTAINS_PATH existence + $builder3->table('users')->whereJsonContains('tags', 'premium')->get(); + expect($connection->lastQuerySql) + ->toContain('JSON_CONTAINS(`tags`, ?)'); + + // JSON_CONTAINS_PATH existence $builder4 = new MySqlQueryBuilder($connection); - $builder4->table('users')->whereJsonExists('data->addr')->get(); - expect($connection->lastQuerySql) - ->toContain("JSON_CONTAINS_PATH(`data`, 'one', '$.addr')"); - - // NOT JSON_CONTAINS_PATH missing + $builder4->table('users')->whereJsonExists('data->addr')->get(); + expect($connection->lastQuerySql) + ->toContain("JSON_CONTAINS_PATH(`data`, 'one', '$.addr')"); + + // NOT JSON_CONTAINS_PATH missing $builder5 = new MySqlQueryBuilder($connection); - $builder5->table('users')->whereJsonMissing('data->addr')->get(); - expect($connection->lastQuerySql) - ->toContain("NOT JSON_CONTAINS_PATH(`data`, 'one', '$.addr')"); - }); + $builder5->table('users')->whereJsonMissing('data->addr')->get(); + expect($connection->lastQuerySql) + ->toContain("NOT JSON_CONTAINS_PATH(`data`, 'one', '$.addr')"); + } + ); it('composes JSON path operators with WHERE, GROUP BY, HAVING, ORDER BY, and LIMIT correctly', function (): void { $connection = new MySqlMockConnection(); diff --git a/packages/database-mysql/tests/Query/MySqlQueryBuilderAggregatesTest.php b/packages/database-mysql/tests/Query/MySqlQueryBuilderAggregatesTest.php index e8a5de72..5f56f393 100644 --- a/packages/database-mysql/tests/Query/MySqlQueryBuilderAggregatesTest.php +++ b/packages/database-mysql/tests/Query/MySqlQueryBuilderAggregatesTest.php @@ -165,29 +165,35 @@ public function lastInsertId(): int expect($min)->toBe(20); }); - it('rejects aggregate column identifiers that fail the identifier whitelist (no SQL injection)', function (): void { - expect(fn () => $this->builder->table('scores')->min('points; DROP TABLE scores--')) - ->toThrow(InvalidColumnException::class) - ->and(fn () => $this->builder->table('scores')->max("col' OR '1'='1")) - ->toThrow(InvalidColumnException::class) - ->and(fn () => $this->builder->table('scores')->sum('1=1')) - ->toThrow(InvalidColumnException::class) - ->and(fn () => $this->builder->table('scores')->avg('/*bad*/')) - ->toThrow(InvalidColumnException::class); - }); + it( + 'rejects aggregate column identifiers that fail the identifier whitelist (no SQL injection)', + function (): void { + expect(fn () => $this->builder->table('scores')->min('points; DROP TABLE scores--')) + ->toThrow(InvalidColumnException::class) + ->and(fn () => $this->builder->table('scores')->max("col' OR '1'='1")) + ->toThrow(InvalidColumnException::class) + ->and(fn () => $this->builder->table('scores')->sum('1=1')) + ->toThrow(InvalidColumnException::class) + ->and(fn () => $this->builder->table('scores')->avg('/*bad*/')) + ->toThrow(InvalidColumnException::class); + } + ); - it('existing int return type of count() remains int (no nullable); only the signature gains an optional column argument', function (): void { - $reflection = new ReflectionClass(MySqlQueryBuilder::class); - $method = $reflection->getMethod('count'); - $returnType = $method->getReturnType(); - $params = $method->getParameters(); - - expect($returnType?->getName())->toBe('int') - ->and($returnType?->allowsNull())->toBeFalse() - ->and($params)->toHaveCount(1) - ->and($params[0]->getName())->toBe('column') - ->and($params[0]->isOptional())->toBeTrue() - ->and($params[0]->allowsNull())->toBeTrue() - ->and($params[0]->getDefaultValue())->toBeNull(); - }); + it( + 'existing int return type of count() remains int (no nullable); only the signature gains an optional column argument', + function (): void { + $reflection = new ReflectionClass(MySqlQueryBuilder::class); + $method = $reflection->getMethod('count'); + $returnType = $method->getReturnType(); + $params = $method->getParameters(); + + expect($returnType?->getName())->toBe('int') + ->and($returnType?->allowsNull())->toBeFalse() + ->and($params)->toHaveCount(1) + ->and($params[0]->getName())->toBe('column') + ->and($params[0]->isOptional())->toBeTrue() + ->and($params[0]->allowsNull())->toBeTrue() + ->and($params[0]->getDefaultValue())->toBeNull(); + } + ); }); diff --git a/packages/database-mysql/tests/Query/MySqlQueryBuilderGroupByTest.php b/packages/database-mysql/tests/Query/MySqlQueryBuilderGroupByTest.php index 3ad7ee6d..4de5d546 100644 --- a/packages/database-mysql/tests/Query/MySqlQueryBuilderGroupByTest.php +++ b/packages/database-mysql/tests/Query/MySqlQueryBuilderGroupByTest.php @@ -29,7 +29,10 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { $this->lastSql = $sql; $this->lastBindings = $bindings; @@ -37,7 +40,10 @@ public function query(string $sql, array $bindings = []): array return []; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { return 0; } @@ -157,19 +163,22 @@ public function rollback(): void {} ); }); - it('validates GROUP BY column identifiers against the alias/identifier whitelist (reuses the whitelist introduced in task 006)', function (): void { - $sql = ''; - $bindings = []; - $conn = makeRecordingConnection($sql, $bindings); - - expect( - fn () => (new MySqlQueryBuilder($conn)) - ->table('orders') - ->select('status') - ->groupBy('status; DROP TABLE orders--') - ->get(), - )->toThrow(InvalidColumnException::class); - }); + it( + 'validates GROUP BY column identifiers against the alias/identifier whitelist (reuses the whitelist introduced in task 006)', + function (): void { + $sql = ''; + $bindings = []; + $conn = makeRecordingConnection($sql, $bindings); + + expect( + fn () => (new MySqlQueryBuilder($conn)) + ->table('orders') + ->select('status') + ->groupBy('status; DROP TABLE orders--') + ->get(), + )->toThrow(InvalidColumnException::class); + } + ); it('rejects HAVING expressions containing semicolons or SQL comments', function (): void { $sql = ''; @@ -204,22 +213,25 @@ public function rollback(): void {} )->toThrow(InvalidColumnException::class); }); - it('composes HAVING bindings with WHERE bindings in the correct positional order at execute time', function (): void { - $sql = ''; - $bindings = []; - $conn = makeRecordingConnection($sql, $bindings); - - (new MySqlQueryBuilder($conn)) - ->table('orders') - ->select('status', 'country') - ->where('active', '=', 1) - ->groupBy('status', 'country') - ->having('COUNT(*) BETWEEN ? AND ?', [3, 10]) - ->get(); - - expect($sql)->toBe( - 'SELECT `status`, `country` FROM `orders` WHERE `active` = ? GROUP BY `status`, `country` HAVING COUNT(*) BETWEEN ? AND ?', - ) - ->and($bindings)->toBe([1, 3, 10]); - }); + it( + 'composes HAVING bindings with WHERE bindings in the correct positional order at execute time', + function (): void { + $sql = ''; + $bindings = []; + $conn = makeRecordingConnection($sql, $bindings); + + (new MySqlQueryBuilder($conn)) + ->table('orders') + ->select('status', 'country') + ->where('active', '=', 1) + ->groupBy('status', 'country') + ->having('COUNT(*) BETWEEN ? AND ?', [3, 10]) + ->get(); + + expect($sql)->toBe( + 'SELECT `status`, `country` FROM `orders` WHERE `active` = ? GROUP BY `status`, `country` HAVING COUNT(*) BETWEEN ? AND ?', + ) + ->and($bindings)->toBe([1, 3, 10]); + } + ); }); diff --git a/packages/database-mysql/tests/Query/MySqlQueryBuilderTest.php b/packages/database-mysql/tests/Query/MySqlQueryBuilderTest.php index af834b9e..e0f287ac 100644 --- a/packages/database-mysql/tests/Query/MySqlQueryBuilderTest.php +++ b/packages/database-mysql/tests/Query/MySqlQueryBuilderTest.php @@ -343,8 +343,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; $this->lastBindings = $bindings; @@ -354,8 +353,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -384,14 +382,14 @@ function (): void { $left = (new MySqlQueryBuilder($this->connection)) ->table('users') ->select('name', 'email'); - + $right = (new MySqlQueryBuilder($this->connection)) ->table('users') ->select('name'); - + expect(fn () => $left->union($right)) ->toThrow(UnionShapeMismatchException::class); - } + }, ); it('combines two queries with UNION ALL preserving duplicates', function (): void { @@ -410,8 +408,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; $this->lastBindings = $bindings; @@ -421,8 +418,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -456,8 +452,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; return []; @@ -466,8 +461,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -498,8 +492,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; return []; @@ -508,8 +501,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -545,8 +537,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; $this->lastBindings = $bindings; @@ -556,8 +547,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -650,35 +640,33 @@ function (): void { $recordingConnection = new class ($recordedSql) extends MySqlConnection { public function __construct(public string &$lastSql) {} - + public function connect(): void {} - + public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; - + return []; } - + public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; - + (new MySqlQueryBuilder($recordingConnection)) ->table('users') ->selectRaw('1 AS one') ->get(); - + expect($recordedSql)->toBe('SELECT *, 1 AS one FROM `users`'); - } + }, ); it( @@ -688,37 +676,35 @@ function (): void { $recordingConnection = new class ($recordedSql) extends MySqlConnection { public function __construct(public string &$lastSql) {} - + public function connect(): void {} - + public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; - + return []; } - + public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; - + (new MySqlQueryBuilder($recordingConnection)) ->table('users') ->select('name') ->selectRaw('1 AS first') ->selectRaw('2 AS second') ->get(); - + expect($recordedSql)->toBe('SELECT `name`, 1 AS first, 2 AS second FROM `users`'); - } + }, ); it( @@ -728,36 +714,34 @@ function (): void { $recordingConnection = new class ($recordedSql) extends MySqlConnection { public function __construct(public string &$lastSql) {} - + public function connect(): void {} - + public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; - + return []; } - + public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; - + (new MySqlQueryBuilder($recordingConnection)) ->table('users') ->select('name', 'email') ->selectRaw('1 AS computed') ->get(); - + expect($recordedSql)->toBe('SELECT `name`, `email`, 1 AS computed FROM `users`'); - } + }, ); it( @@ -767,36 +751,34 @@ function (): void { $recordingConnection = new class ($recordedBindings) extends MySqlConnection { public function __construct(public array &$lastBindings) {} - + public function connect(): void {} - + public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastBindings = $bindings; - + return []; } - + public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; - + (new MySqlQueryBuilder($recordingConnection)) ->table('users') ->selectRaw('? AS val', [42]) ->where('status', '=', 'active') ->get(); - + expect($recordedBindings)->toBe([42, 'active']); - } + }, ); it('selectRaw bindings from multiple calls concatenate in call order', function (): void { @@ -810,8 +792,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastBindings = $bindings; return []; @@ -820,8 +801,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -846,40 +826,38 @@ public function __construct( public array &$sqls, public array &$bindingsList, ) {} - + public function connect(): void {} - + public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->sqls[] = $sql; $this->bindingsList[] = $bindings; - + return []; } - + public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; - + $builder = (new MySqlQueryBuilder($recordingConnection)) ->table('users') ->selectRaw('? AS val', [99]) ->where('status', '=', 'active'); - + $builder->get(); $builder->get(); - + expect($sqls[0])->toBe($sqls[1]) ->and($bindingsList[0])->toBe($bindingsList[1]); - } + }, ); it('selectRaw throws InvalidColumnException when the expression contains a semicolon', function (): void { @@ -892,7 +870,7 @@ public function execute( function (): void { expect(fn () => $this->builder->selectRaw('1 -- comment')) ->toThrow(InvalidColumnException::class); - } + }, ); it( @@ -900,7 +878,7 @@ function (): void { function (): void { expect(fn () => $this->builder->selectRaw('/* comment */ 1')) ->toThrow(InvalidColumnException::class); - } + }, ); it('selectRaw throws InvalidColumnException when the expression contains a backtick', function (): void { @@ -924,12 +902,12 @@ function (): void { ->where('status', '=', 'active') ->whereRaw("name != 'Bob'") ->get(); - + $names = array_column($results, 'name'); expect($results)->toHaveCount(2) ->and($names)->toContain('Alice') ->and($names)->toContain('Charlie'); - } + }, ); it('whereRaw used alone (no prior where) emits "WHERE " with no leading AND', function (): void { @@ -943,8 +921,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; return []; @@ -953,8 +930,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -978,8 +954,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; return []; @@ -988,8 +963,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -1010,36 +984,34 @@ function (): void { $recordingConnection = new class ($recordedSql) extends MySqlConnection { public function __construct(public string &$lastSql) {} - + public function connect(): void {} - + public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; - + return []; } - + public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; - + (new MySqlQueryBuilder($recordingConnection)) ->table('users') ->where('status', '=', 'active') ->whereRaw('name != ?', ['Bob']) ->get(); - + expect($recordedSql)->toBe('SELECT * FROM `users` WHERE `status` = ? AND name != ?'); - } + }, ); it( @@ -1049,37 +1021,35 @@ function (): void { $recordingConnection = new class ($recordedBindings) extends MySqlConnection { public function __construct(public array &$lastBindings) {} - + public function connect(): void {} - + public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastBindings = $bindings; - + return []; } - + public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; - + (new MySqlQueryBuilder($recordingConnection)) ->table('users') ->selectRaw('? AS sel', ['sel_val']) ->where('status', '=', 'active') ->whereRaw('name != ?', ['Bob']) ->get(); - + expect($recordedBindings)->toBe(['sel_val', 'active', 'Bob']); - } + }, ); it('whereRaw bindings from multiple calls concatenate in call order', function (): void { @@ -1093,8 +1063,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastBindings = $bindings; return []; @@ -1103,8 +1072,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -1152,7 +1120,7 @@ public function execute( function (): void { expect(fn () => $this->builder->whereRaw('1 -- comment')) ->toThrow(InvalidColumnException::class); - } + }, ); it( @@ -1160,7 +1128,7 @@ function (): void { function (): void { expect(fn () => $this->builder->whereRaw('/* comment */ 1')) ->toThrow(InvalidColumnException::class); - } + }, ); it('whereRaw throws InvalidColumnException when the expression contains a backtick', function (): void { @@ -1182,36 +1150,34 @@ function (): void { $recordingConnection = new class ($recordedBindings) extends MySqlConnection { public function __construct(public array &$lastBindings) {} - + public function connect(): void {} - + public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastBindings = $bindings; - + return []; } - + public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; - + (new MySqlQueryBuilder($recordingConnection)) ->table('users') ->selectRaw('? AS sel', ['sel_val']) ->whereRaw('status = ?', ['active']) ->get(); - + expect($recordedBindings)->toBe(['sel_val', 'active']); - } + }, ); it( @@ -1225,45 +1191,43 @@ public function __construct( public string &$lastSql, public array &$lastBindings, ) {} - + public function connect(): void {} - + public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; $this->lastBindings = $bindings; - + return []; } - + public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; - + $left = (new MySqlQueryBuilder($recordingConnection)) ->table('users') ->select('name'); - + $right = (new MySqlQueryBuilder($recordingConnection)) ->table('users') ->select('name') ->selectRaw('? AS sel', ['sel_val']) ->whereRaw('status = ?', ['active']); - + $left->union($right)->get(); - + expect($recordedSql)->toContain('? AS sel') ->and($recordedSql)->toContain('status = ?') ->and($recordedBindings)->toBe(['sel_val', 'active']); - } + }, ); }); @@ -1279,8 +1243,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; return []; @@ -1289,8 +1252,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -1315,8 +1277,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; return []; @@ -1325,8 +1286,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; @@ -1351,8 +1311,7 @@ public function connect(): void {} public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; return []; @@ -1361,8 +1320,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } }; diff --git a/packages/database-pgsql/src/Query/PgSqlQueryBuilder.php b/packages/database-pgsql/src/Query/PgSqlQueryBuilder.php index 94c6a03f..673c769e 100644 --- a/packages/database-pgsql/src/Query/PgSqlQueryBuilder.php +++ b/packages/database-pgsql/src/Query/PgSqlQueryBuilder.php @@ -299,7 +299,7 @@ public function groupBy( foreach ($columns as $column) { if (!IdentifierValidator::isValidIdentifier($column) && !preg_match( '/^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/', - $column + $column, )) { throw InvalidColumnException::invalidColumn($column); } diff --git a/packages/database-pgsql/tests/Fixtures/Variant/VariantQueryBuilder.php b/packages/database-pgsql/tests/Fixtures/Variant/VariantQueryBuilder.php index e5b7561f..d69e456a 100644 --- a/packages/database-pgsql/tests/Fixtures/Variant/VariantQueryBuilder.php +++ b/packages/database-pgsql/tests/Fixtures/Variant/VariantQueryBuilder.php @@ -21,8 +21,7 @@ public function select(string ...$columns): static public function selectRaw( string $expression, array $bindings = [], - ): static - { + ): static { return $this; } @@ -35,16 +34,14 @@ public function where( string $column, string $operator, mixed $value, - ): static - { + ): static { return $this; } public function whereIn( string $column, array $values, - ): static - { + ): static { return $this; } @@ -61,8 +58,7 @@ public function whereNotNull(string $column): static public function whereJsonContains( string $path, mixed $value, - ): static - { + ): static { return $this; } @@ -80,16 +76,14 @@ public function orWhere( string $column, string $operator, mixed $value, - ): static - { + ): static { return $this; } public function whereRaw( string $expression, array $bindings = [], - ): static - { + ): static { return $this; } @@ -98,8 +92,7 @@ public function join( string $first, string $operator, string $second, - ): static - { + ): static { return $this; } @@ -108,8 +101,7 @@ public function leftJoin( string $first, string $operator, string $second, - ): static - { + ): static { return $this; } @@ -118,8 +110,7 @@ public function rightJoin( string $first, string $operator, string $second, - ): static - { + ): static { return $this; } @@ -131,24 +122,21 @@ public function groupBy(string ...$columns): static public function having( string $expression, array $bindings = [], - ): static - { + ): static { return $this; } public function orderBy( string $column, string $direction = 'ASC', - ): static - { + ): static { return $this; } public function orderByRaw( string $expression, string $direction = 'ASC', - ): static - { + ): static { return $this; } @@ -235,8 +223,7 @@ public function avg(string $column): int|float|null public function raw( string $sql, array $bindings = [], - ): array - { + ): array { return []; } } diff --git a/packages/database-pgsql/tests/Fixtures/Variant/VariantSqlGenerator.php b/packages/database-pgsql/tests/Fixtures/Variant/VariantSqlGenerator.php index b557e03d..97c092cc 100644 --- a/packages/database-pgsql/tests/Fixtures/Variant/VariantSqlGenerator.php +++ b/packages/database-pgsql/tests/Fixtures/Variant/VariantSqlGenerator.php @@ -36,16 +36,14 @@ public function generateDropTable(string $tableName): string public function generateAddColumn( string $table, Column $column, - ): string - { + ): string { return ''; } public function generateDropColumn( string $table, string $columnName, - ): string - { + ): string { return ''; } @@ -53,40 +51,35 @@ public function generateModifyColumn( string $table, Column $column, Column $oldColumn, - ): string - { + ): string { return ''; } public function generateAddIndex( string $table, Index $index, - ): string - { + ): string { return ''; } public function generateDropIndex( string $table, string $indexName, - ): string - { + ): string { return ''; } public function generateAddForeignKey( string $table, ForeignKey $foreignKey, - ): string - { + ): string { return ''; } public function generateDropForeignKey( string $table, string $keyName, - ): string - { + ): string { return ''; } } diff --git a/packages/database-pgsql/tests/Module/DialectOverrideTest.php b/packages/database-pgsql/tests/Module/DialectOverrideTest.php index 3c2fd162..be51851f 100644 --- a/packages/database-pgsql/tests/Module/DialectOverrideTest.php +++ b/packages/database-pgsql/tests/Module/DialectOverrideTest.php @@ -104,31 +104,32 @@ function buildVariantContainer(): Container 'still resolves ConnectionInterface to PgSqlConnection because the variant did not rebind it', function (): void { $container = buildVariantContainer(); - + // PgSqlConnection requires DatabaseConfig which in turn requires ProjectPaths. - // Resolving the class binding is enough to prove ConnectionInterface is still - // bound to PgSqlConnection — we verify by inspecting the binding, not by - // constructing the full object (which requires a real config file). - // We do this by binding a minimal stub for DatabaseConfig's dependency and - // checking the binding resolves to the correct class. - // - // Simpler: just assert the container would resolve to PgSqlConnection by - // checking it IS registered and not overridden by the variant. - // We use the container's has() + a Closure binding trick to inspect without - // instantiating the full dependency graph. - $container2 = buildVariantContainer(); + // Resolving the class binding is enough to prove ConnectionInterface is still + // bound to PgSqlConnection — we verify by inspecting the binding, not by + // constructing the full object (which requires a real config file). + // We do this by binding a minimal stub for DatabaseConfig's dependency and + // checking the binding resolves to the correct class. + // + // Simpler: just assert the container would resolve to PgSqlConnection by + // checking it IS registered and not overridden by the variant. + // We use the container's has() + a Closure binding trick to inspect without + // instantiating the full dependency graph. + $container2 = buildVariantContainer(); $container2->bind( ConnectionInterface::class, /** @noinspection PhpMissingParentConstructorInspection - Test stub intentionally skips parent */ - static fn () => new class () extends PgSqlConnection { + static fn () => new class () extends PgSqlConnection + { /** @noinspection PhpMissingParentConstructorInspection */ public function __construct() {} }, ); - + expect($container2->get(ConnectionInterface::class)) ->toBeInstanceOf(PgSqlConnection::class); - } + }, ); it( @@ -136,14 +137,14 @@ public function __construct() {} function (): void { $modulePath = dirname(__DIR__, 2); $pgsqlConfig = require $modulePath . '/module.php'; - + $moduleA = new ModuleManifest( name: 'marko/database-pgsql', version: '1.0.0', bindings: $pgsqlConfig['bindings'], source: 'vendor', ); - + $moduleB = new ModuleManifest( name: 'acme/database-cockroach', version: '1.0.0', @@ -152,14 +153,14 @@ function (): void { ], source: 'vendor', ); - + $container = new Container(); $registry = new BindingRegistry($container); $registry->registerModule($moduleA); - + expect(static fn () => $registry->registerModule($moduleB)) ->toThrow(BindingConflictException::class); - } + }, ); it( @@ -167,16 +168,16 @@ function (): void { function (): void { $modulePath = dirname(__DIR__, 2); $pgsqlConfig = require $modulePath . '/module.php'; - + $singletons = $pgsqlConfig['singletons'] ?? []; - + $dialectInterfaces = [ SqlGeneratorInterface::class, IntrospectorInterface::class, QueryBuilderInterface::class, QueryBuilderFactoryInterface::class, ]; - + foreach ($dialectInterfaces as $interface) { expect(in_array($interface, $singletons, strict: true))->toBeFalse( "Expected $interface to NOT be in pgsql singletons, but it was. " @@ -187,6 +188,6 @@ function (): void { . 'Declaring dialect interfaces as singletons would break the boot-callback override pattern.', ); } - } + }, ); }); diff --git a/packages/database-pgsql/tests/Query/PgSqlJsonQueryBuilderTest.php b/packages/database-pgsql/tests/Query/PgSqlJsonQueryBuilderTest.php index 02256460..afb75d2d 100644 --- a/packages/database-pgsql/tests/Query/PgSqlJsonQueryBuilderTest.php +++ b/packages/database-pgsql/tests/Query/PgSqlJsonQueryBuilderTest.php @@ -104,16 +104,16 @@ function (): void { queryReturn: [['id' => 2]], ); $builder = new PgSqlQueryBuilder($connection); - + $result = $builder ->table('users') ->whereJsonContains('data->roles', 'admin') ->get(); - + expect($connection->lastQuerySql) ->toContain('"data"->\'roles\' @> ?') ->and($result)->toHaveCount(1); - } + }, ); it('returns rows where a JSON path exists via whereJsonExists()', function (): void { diff --git a/packages/database-pgsql/tests/Query/PgSqlQueryBuilderGroupByTest.php b/packages/database-pgsql/tests/Query/PgSqlQueryBuilderGroupByTest.php index a483c335..1d0e5e33 100644 --- a/packages/database-pgsql/tests/Query/PgSqlQueryBuilderGroupByTest.php +++ b/packages/database-pgsql/tests/Query/PgSqlQueryBuilderGroupByTest.php @@ -98,7 +98,7 @@ 'validates GROUP BY column identifiers against the alias/identifier whitelist (reuses the whitelist introduced in task 006)', function (): void { $conn = new MockConnection(); - + expect( fn () => (new PgSqlQueryBuilder($conn)) ->table('orders') @@ -106,7 +106,7 @@ function (): void { ->groupBy('status; DROP TABLE orders--') ->get(), )->toThrow(InvalidColumnException::class); - } + }, ); it('rejects HAVING expressions containing semicolons or SQL comments', function (): void { @@ -144,7 +144,7 @@ function (): void { 'composes HAVING bindings with WHERE bindings in the correct positional order at execute time', function (): void { $conn = new MockConnection(); - + (new PgSqlQueryBuilder($conn)) ->table('orders') ->select('status', 'country') @@ -152,11 +152,11 @@ function (): void { ->groupBy('status', 'country') ->having('COUNT(*) BETWEEN ? AND ?', [3, 10]) ->get(); - + expect($conn->lastQuerySql)->toBe( 'SELECT "status", "country" FROM "orders" WHERE "active" = ? GROUP BY "status", "country" HAVING COUNT(*) BETWEEN ? AND ?', ) ->and($conn->lastQueryBindings)->toBe([1, 3, 10]); - } + }, ); }); diff --git a/packages/database-pgsql/tests/Query/PgSqlQueryBuilderTest.php b/packages/database-pgsql/tests/Query/PgSqlQueryBuilderTest.php index ed2e5bae..25eb3e6d 100644 --- a/packages/database-pgsql/tests/Query/PgSqlQueryBuilderTest.php +++ b/packages/database-pgsql/tests/Query/PgSqlQueryBuilderTest.php @@ -258,16 +258,16 @@ 'throws UnionShapeMismatchException when the two queries select different numbers of columns', function (): void { $connection = new MockConnection(); - + $left = new PgSqlQueryBuilder($connection); $left->table('users')->select('name', 'email'); - + $right = new PgSqlQueryBuilder(new MockConnection()); $right->table('admins')->select('name'); - + expect(fn () => $left->union($right)) ->toThrow(UnionShapeMismatchException::class); - } + }, ); it('combines two queries with UNION ALL preserving duplicates', function (): void { diff --git a/packages/database-readwrite/tests/Module/ModuleBootTest.php b/packages/database-readwrite/tests/Module/ModuleBootTest.php index 0dafe374..c53a8684 100644 --- a/packages/database-readwrite/tests/Module/ModuleBootTest.php +++ b/packages/database-readwrite/tests/Module/ModuleBootTest.php @@ -29,16 +29,14 @@ public function isConnected(): bool public function query( string $sql, array $bindings = [], - ): array - { + ): array { return []; } public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } @@ -98,8 +96,7 @@ public function __construct( public function get( string $key, ?string $scope = null, - ): mixed - { + ): mixed { return match ($key) { 'database.driver' => $this->driver, 'database.connections' => [ @@ -114,48 +111,42 @@ public function get( public function has( string $key, ?string $scope = null, - ): bool - { + ): bool { return true; } public function getString( string $key, ?string $scope = null, - ): string - { + ): string { return ''; } public function getInt( string $key, ?string $scope = null, - ): int - { + ): int { return 0; } public function getBool( string $key, ?string $scope = null, - ): bool - { + ): bool { return false; } public function getFloat( string $key, ?string $scope = null, - ): float - { + ): float { return 0.0; } public function getArray( string $key, ?string $scope = null, - ): array - { + ): array { return []; } @@ -202,8 +193,7 @@ public function singleton(string $id): void {} public function instance( string $id, object $instance, - ): void - { + ): void { $this->registered[$id] = $instance; } @@ -311,8 +301,7 @@ public function make(DatabaseConfig $config): ConnectionInterface public function get( string $key, ?string $scope = null, - ): mixed - { + ): mixed { return match ($key) { 'database.driver' => 'readwrite', 'database.connections' => [ @@ -330,48 +319,42 @@ public function get( public function has( string $key, ?string $scope = null, - ): bool - { + ): bool { return true; } public function getString( string $key, ?string $scope = null, - ): string - { + ): string { return ''; } public function getInt( string $key, ?string $scope = null, - ): int - { + ): int { return 0; } public function getBool( string $key, ?string $scope = null, - ): bool - { + ): bool { return false; } public function getFloat( string $key, ?string $scope = null, - ): float - { + ): float { return 0.0; } public function getArray( string $key, ?string $scope = null, - ): array - { + ): array { return []; } diff --git a/packages/database/src/Entity/EntityCollection.php b/packages/database/src/Entity/EntityCollection.php index 996323a4..c21ecb46 100644 --- a/packages/database/src/Entity/EntityCollection.php +++ b/packages/database/src/Entity/EntityCollection.php @@ -115,7 +115,10 @@ public function pluck(string $property): array /** * @return self */ - public function sortBy(string $property, bool $descending = false): self + public function sortBy( + string $property, + bool $descending = false, + ): self { $sorted = $this->entities; usort($sorted, function (Entity $a, Entity $b) use ($property, $descending): int { diff --git a/packages/database/src/Entity/EntityCompanionStorage.php b/packages/database/src/Entity/EntityCompanionStorage.php index bfb99947..5e99eec3 100644 --- a/packages/database/src/Entity/EntityCompanionStorage.php +++ b/packages/database/src/Entity/EntityCompanionStorage.php @@ -67,7 +67,10 @@ public function get(Entity $entity): array /** * Attach a companion to an entity, keyed by the companion's class. */ - public function attach(Entity $entity, Entity $companion): void + public function attach( + Entity $entity, + Entity $companion, + ): void { $bag = $this->companions[$entity] ?? []; $bag[$companion::class] = $companion; diff --git a/packages/database/src/Entity/EntityHydrator.php b/packages/database/src/Entity/EntityHydrator.php index 103754ec..5ad3a0fb 100644 --- a/packages/database/src/Entity/EntityHydrator.php +++ b/packages/database/src/Entity/EntityHydrator.php @@ -235,7 +235,10 @@ public function registerOriginalValues( * Delegates into the shared EntityCompanionStorage so companions set here * are visible through Entity::companions() / Entity::companion(). */ - public function attachCompanion(Entity $entity, Entity $companion): void + public function attachCompanion( + Entity $entity, + Entity $companion, + ): void { EntityCompanionStorage::instance()->attach($entity, $companion); } diff --git a/packages/database/src/Entity/RelationshipLoader.php b/packages/database/src/Entity/RelationshipLoader.php index f245360f..3812f5ac 100644 --- a/packages/database/src/Entity/RelationshipLoader.php +++ b/packages/database/src/Entity/RelationshipLoader.php @@ -410,7 +410,10 @@ private function loadBelongsToMany( * @param Entity[] $entities * @return array */ - private function collectPropertyValues(array $entities, string $propertyName): array + private function collectPropertyValues( + array $entities, + string $propertyName, + ): array { return array_map(fn (Entity $entity) => $this->getPropertyValue($entity, $propertyName), $entities); } @@ -418,7 +421,10 @@ private function collectPropertyValues(array $entities, string $propertyName): a /** * Get a property value from an entity via reflection. */ - private function getPropertyValue(Entity $entity, string $propertyName): mixed + private function getPropertyValue( + Entity $entity, + string $propertyName, + ): mixed { $reflection = new ReflectionClass($entity); $property = $reflection->getProperty($propertyName); @@ -433,7 +439,11 @@ private function getPropertyValue(Entity $entity, string $propertyName): mixed /** * Set a property value on an entity via reflection. */ - private function setProperty(Entity $entity, string $propertyName, mixed $value): void + private function setProperty( + Entity $entity, + string $propertyName, + mixed $value, + ): void { $reflection = new ReflectionClass($entity); $property = $reflection->getProperty($propertyName); @@ -454,7 +464,11 @@ private function setProperty(Entity $entity, string $propertyName, mixed $value) * * @param Entity[] $entities */ - private function setPropertyOnAll(array $entities, string $propertyName, mixed $value): void + private function setPropertyOnAll( + array $entities, + string $propertyName, + mixed $value, + ): void { foreach ($entities as $entity) { $this->setProperty($entity, $propertyName, $value); diff --git a/packages/database/src/Repository/RepositoryQueryBuilder.php b/packages/database/src/Repository/RepositoryQueryBuilder.php index b739f252..cfc77f93 100644 --- a/packages/database/src/Repository/RepositoryQueryBuilder.php +++ b/packages/database/src/Repository/RepositoryQueryBuilder.php @@ -102,7 +102,10 @@ public function whereNotNull( return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { $this->queryBuilder->whereJsonContains($path, $value); @@ -192,7 +195,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { $this->queryBuilder->having($expression, $bindings); diff --git a/packages/database/src/Schema/SchemaRegistry.php b/packages/database/src/Schema/SchemaRegistry.php index f46544dc..9bff39e1 100644 --- a/packages/database/src/Schema/SchemaRegistry.php +++ b/packages/database/src/Schema/SchemaRegistry.php @@ -168,7 +168,10 @@ public function registerEntities( } // Merge foreign keys (use parent table name for FK name generation) - foreach ($this->schemaBuilder->buildForeignKeysForTable($parentMetadata->tableName, $extenderMetadata->columns) as $fk) { + foreach ($this->schemaBuilder->buildForeignKeysForTable( + $parentMetadata->tableName, + $extenderMetadata->columns + ) as $fk) { $table = $table->withForeignKey($fk); } } diff --git a/packages/database/tests/Attributes/RelationshipAttributeTest.php b/packages/database/tests/Attributes/RelationshipAttributeTest.php index 99f44db8..65452f96 100644 --- a/packages/database/tests/Attributes/RelationshipAttributeTest.php +++ b/packages/database/tests/Attributes/RelationshipAttributeTest.php @@ -21,7 +21,12 @@ class RelUserEntity extends Entity #[HasMany(RelPostEntity::class, foreignKey: 'author_id')] public array $posts = []; - #[BelongsToMany(RelRoleEntity::class, pivotClass: RelUserRoleEntity::class, foreignKey: 'user_id', relatedKey: 'role_id')] + #[BelongsToMany( + RelRoleEntity::class, + pivotClass: RelUserRoleEntity::class, + foreignKey: 'user_id', + relatedKey: 'role_id' + )] public array $roles = []; } diff --git a/packages/database/tests/Entity/EntityCollectionTest.php b/packages/database/tests/Entity/EntityCollectionTest.php index 69b9593e..939325b9 100644 --- a/packages/database/tests/Entity/EntityCollectionTest.php +++ b/packages/database/tests/Entity/EntityCollectionTest.php @@ -160,7 +160,9 @@ function makeEntity(int $id, string $name, ?string $role = null): Entity it('checks contains with callback returning false when no match', function (): void { $collection = new EntityCollection([makeEntity(1, 'Alice'), makeEntity(2, 'Bob')]); - expect($collection->contains(fn (Entity $e): bool => $e->name === 'Charlie'))->toBeFalse(); // @phpstan-ignore-line + expect( + $collection->contains(fn (Entity $e): bool => $e->name === 'Charlie') + )->toBeFalse(); // @phpstan-ignore-line }); }); diff --git a/packages/database/tests/Entity/EntityHydratorTest.php b/packages/database/tests/Entity/EntityHydratorTest.php index 977a4049..676d4389 100644 --- a/packages/database/tests/Entity/EntityHydratorTest.php +++ b/packages/database/tests/Entity/EntityHydratorTest.php @@ -663,7 +663,9 @@ public function parse(string $entityClass): EntityMetadata expect($entity->companions())->toHaveCount(2) ->and($entity->companion(HydratorTestProductExt::class))->toBeInstanceOf(HydratorTestProductExt::class) - ->and($entity->companion(HydratorTestProductPricing::class))->toBeInstanceOf(HydratorTestProductPricing::class); + ->and($entity->companion(HydratorTestProductPricing::class))->toBeInstanceOf( + HydratorTestProductPricing::class + ); }); it('attaches each companion under its own class-string in the companions bag', function (): void { @@ -815,51 +817,60 @@ public function parse(string $entityClass): EntityMetadata ->and($extracted['price'])->toBe(19.99); }); -it('does not require the EntityMetadataFactory call for entities without extenders (no extra parse)', function (): void { - // Factory with no entries — if parse() is called it will throw a key error +it( + 'does not require the EntityMetadataFactory call for entities without extenders (no extra parse)', + function (): void { + // Factory with no entries — if parse() is called it will throw a key error $factory = createStubMetadataFactory([]); - $hydrator = new EntityHydrator($factory); - $metadata = createProductMetadata(); // extenders: [] - - $row = ['id' => 1, 'name' => 'Safe']; + $hydrator = new EntityHydrator($factory); + $metadata = createProductMetadata(); // extenders: [] - // Should not throw even though factory has no entries + $row = ['id' => 1, 'name' => 'Safe']; + + // Should not throw even though factory has no entries /** @var HydratorTestProduct $entity */ - $entity = $hydrator->hydrate(HydratorTestProduct::class, $row, $metadata); - - expect($entity)->toBeInstanceOf(HydratorTestProduct::class); -}); - -it('constructs without the EntityMetadataFactory and hydrates non-extended entities correctly (backward compat)', function (): void { - $hydrator = new EntityHydrator(); // no factory + $entity = $hydrator->hydrate(HydratorTestProduct::class, $row, $metadata); + + expect($entity)->toBeInstanceOf(HydratorTestProduct::class); + } +); + +it( + 'constructs without the EntityMetadataFactory and hydrates non-extended entities correctly (backward compat)', + function (): void { + $hydrator = new EntityHydrator(); // no factory $metadata = createProductMetadata(); // extenders: [] - $row = ['id' => 10, 'name' => 'Compat']; - /** @var HydratorTestProduct $entity */ - $entity = $hydrator->hydrate(HydratorTestProduct::class, $row, $metadata); - - expect($entity)->toBeInstanceOf(HydratorTestProduct::class) - ->and($entity->id)->toBe(10) - ->and($entity->name)->toBe('Compat'); -}); - -it('correctly hydrates companions when the factory has not seen the extender classes before (on-demand parse)', function (): void { - // Use a real EntityMetadataFactory — it has never parsed HydratorTestProductExt before + $row = ['id' => 10, 'name' => 'Compat']; + /** @var HydratorTestProduct $entity */ + $entity = $hydrator->hydrate(HydratorTestProduct::class, $row, $metadata); + + expect($entity)->toBeInstanceOf(HydratorTestProduct::class) + ->and($entity->id)->toBe(10) + ->and($entity->name)->toBe('Compat'); + } +); + +it( + 'correctly hydrates companions when the factory has not seen the extender classes before (on-demand parse)', + function (): void { + // Use a real EntityMetadataFactory — it has never parsed HydratorTestProductExt before $factory = new EntityMetadataFactory(); - $hydrator = new EntityHydrator($factory); - $metadata = createProductMetadataWithExtenders(HydratorTestProductExt::class); - - $row = ['id' => 4, 'name' => 'Fresh', 'sku' => 'FRS-04', 'stock' => 7]; - /** @var HydratorTestProduct $entity */ - $entity = $hydrator->hydrate(HydratorTestProduct::class, $row, $metadata); - - /** @var HydratorTestProductExt $ext */ - $ext = $entity->companion(HydratorTestProductExt::class); - - expect($ext)->toBeInstanceOf(HydratorTestProductExt::class) - ->and($ext->sku)->toBe('FRS-04') - ->and($ext->stock)->toBe(7); -}); + $hydrator = new EntityHydrator($factory); + $metadata = createProductMetadataWithExtenders(HydratorTestProductExt::class); + + $row = ['id' => 4, 'name' => 'Fresh', 'sku' => 'FRS-04', 'stock' => 7]; + /** @var HydratorTestProduct $entity */ + $entity = $hydrator->hydrate(HydratorTestProduct::class, $row, $metadata); + + /** @var HydratorTestProductExt $ext */ + $ext = $entity->companion(HydratorTestProductExt::class); + + expect($ext)->toBeInstanceOf(HydratorTestProductExt::class) + ->and($ext->sku)->toBe('FRS-04') + ->and($ext->stock)->toBe(7); + } +); // ------------------------------------------------------------------------- // Helper functions to create metadata diff --git a/packages/database/tests/Entity/EntityMetadataFactoryRelationshipTest.php b/packages/database/tests/Entity/EntityMetadataFactoryRelationshipTest.php index 29890096..c4c3000c 100644 --- a/packages/database/tests/Entity/EntityMetadataFactoryRelationshipTest.php +++ b/packages/database/tests/Entity/EntityMetadataFactoryRelationshipTest.php @@ -303,7 +303,12 @@ #[Column(primaryKey: true, autoIncrement: true)] public int $id; - #[BelongsToMany(entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', relatedKey: 'role_id')] + #[BelongsToMany( + entityClass: Entity::class, + pivotClass: Entity::class, + foreignKey: 'user_id', + relatedKey: 'role_id' + )] public array $roles = []; }; @@ -318,7 +323,12 @@ #[Column(primaryKey: true, autoIncrement: true)] public int $id; - #[BelongsToMany(entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', relatedKey: 'role_id')] + #[BelongsToMany( + entityClass: Entity::class, + pivotClass: Entity::class, + foreignKey: 'user_id', + relatedKey: 'role_id' + )] public array $roles = []; }; @@ -333,7 +343,12 @@ #[Column(primaryKey: true, autoIncrement: true)] public int $id; - #[BelongsToMany(entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', relatedKey: 'role_id')] + #[BelongsToMany( + entityClass: Entity::class, + pivotClass: Entity::class, + foreignKey: 'user_id', + relatedKey: 'role_id' + )] public array $roles = []; }; @@ -348,7 +363,12 @@ #[Column(primaryKey: true, autoIncrement: true)] public int $id; - #[BelongsToMany(entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', relatedKey: 'role_id')] + #[BelongsToMany( + entityClass: Entity::class, + pivotClass: Entity::class, + foreignKey: 'user_id', + relatedKey: 'role_id' + )] public array $roles = []; }; @@ -363,7 +383,12 @@ #[Column(primaryKey: true, autoIncrement: true)] public int $id; - #[BelongsToMany(entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', relatedKey: 'role_id')] + #[BelongsToMany( + entityClass: Entity::class, + pivotClass: Entity::class, + foreignKey: 'user_id', + relatedKey: 'role_id' + )] public array $roles = []; }; @@ -378,7 +403,12 @@ #[Column(primaryKey: true, autoIncrement: true)] public int $id; - #[BelongsToMany(entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', relatedKey: 'role_id')] + #[BelongsToMany( + entityClass: Entity::class, + pivotClass: Entity::class, + foreignKey: 'user_id', + relatedKey: 'role_id' + )] public array $roles = []; }; @@ -450,7 +480,12 @@ #[Column(primaryKey: true, autoIncrement: true)] public int $id; - #[BelongsToMany(entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', relatedKey: 'role_id')] + #[BelongsToMany( + entityClass: Entity::class, + pivotClass: Entity::class, + foreignKey: 'user_id', + relatedKey: 'role_id' + )] public EntityCollection $roles; }; diff --git a/packages/database/tests/Entity/EntityTest.php b/packages/database/tests/Entity/EntityTest.php index 314fcbd2..5a1500db 100644 --- a/packages/database/tests/Entity/EntityTest.php +++ b/packages/database/tests/Entity/EntityTest.php @@ -140,18 +140,21 @@ class AnotherCompanionEntity extends Entity ->and($parent->companion(CompanionEntity::class))->toBe($second); }); -it('shares storage between the public Entity::attachCompanion and the internal hydrator attach (one WeakMap, not two)', function (): void { - $hydrator = new EntityHydrator(); - $parent = new ParentEntity(); - $companionViaEntity = new CompanionEntity(); - $companionViaHydrator = new AnotherCompanionEntity(); - - // Attach one via Entity public API, one via hydrator internal API +it( + 'shares storage between the public Entity::attachCompanion and the internal hydrator attach (one WeakMap, not two)', + function (): void { + $hydrator = new EntityHydrator(); + $parent = new ParentEntity(); + $companionViaEntity = new CompanionEntity(); + $companionViaHydrator = new AnotherCompanionEntity(); + + // Attach one via Entity public API, one via hydrator internal API $parent->attachCompanion($companionViaEntity); - $hydrator->attachCompanion($parent, $companionViaHydrator); - - // Both are visible through the same Entity::companions() call + $hydrator->attachCompanion($parent, $companionViaHydrator); + + // Both are visible through the same Entity::companions() call expect($parent->companions())->toHaveCount(2) - ->and($parent->companion(CompanionEntity::class))->toBe($companionViaEntity) - ->and($parent->companion(AnotherCompanionEntity::class))->toBe($companionViaHydrator); -}); + ->and($parent->companion(CompanionEntity::class))->toBe($companionViaEntity) + ->and($parent->companion(AnotherCompanionEntity::class))->toBe($companionViaHydrator); + } +); diff --git a/packages/database/tests/Entity/RelationshipLoaderBelongsToManyTest.php b/packages/database/tests/Entity/RelationshipLoaderBelongsToManyTest.php index 1ada1496..f9e3edbf 100644 --- a/packages/database/tests/Entity/RelationshipLoaderBelongsToManyTest.php +++ b/packages/database/tests/Entity/RelationshipLoaderBelongsToManyTest.php @@ -373,7 +373,10 @@ public function raw( return []; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -393,7 +396,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -649,7 +655,10 @@ public function raw( return []; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -669,7 +678,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } diff --git a/packages/database/tests/Entity/RelationshipLoaderNestedTest.php b/packages/database/tests/Entity/RelationshipLoaderNestedTest.php index 92a45c75..ef49a1db 100644 --- a/packages/database/tests/Entity/RelationshipLoaderNestedTest.php +++ b/packages/database/tests/Entity/RelationshipLoaderNestedTest.php @@ -383,7 +383,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -538,7 +541,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } diff --git a/packages/database/tests/Entity/RelationshipLoaderTest.php b/packages/database/tests/Entity/RelationshipLoaderTest.php index b06aebe9..252b0b1e 100644 --- a/packages/database/tests/Entity/RelationshipLoaderTest.php +++ b/packages/database/tests/Entity/RelationshipLoaderTest.php @@ -291,7 +291,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -446,7 +449,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -545,7 +551,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -700,7 +709,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } diff --git a/packages/database/tests/Entity/RelationshipValidationTest.php b/packages/database/tests/Entity/RelationshipValidationTest.php index 4b2d4511..6f74c498 100644 --- a/packages/database/tests/Entity/RelationshipValidationTest.php +++ b/packages/database/tests/Entity/RelationshipValidationTest.php @@ -83,7 +83,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -238,7 +241,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } diff --git a/packages/database/tests/Integration/TableExtensionIntegrationTest.php b/packages/database/tests/Integration/TableExtensionIntegrationTest.php index f2f1aa2f..488210f5 100644 --- a/packages/database/tests/Integration/TableExtensionIntegrationTest.php +++ b/packages/database/tests/Integration/TableExtensionIntegrationTest.php @@ -67,7 +67,10 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { $statement = $this->pdo->prepare($sql); $statement->execute($bindings); @@ -75,7 +78,10 @@ public function query(string $sql, array $bindings = []): array return $statement->fetchAll(PDO::FETCH_ASSOC); } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { $statement = $this->pdo->prepare($sql); $statement->execute($bindings); @@ -292,39 +298,42 @@ function buildSchemaAndCreateTable( ->and($rows[0]['timezone'])->toBe('Asia/Shanghai'); }); - it('silently skips companion hydration when the extender\'s columns are dropped from the schema (rolling deploy)', function (): void { - $metadataFactory = new EntityMetadataFactory(); - $connection = createSqliteConnection(); - - // Create a table WITHOUT the extender columns (simulates rolling deploy where + it( + 'silently skips companion hydration when the extender\'s columns are dropped from the schema (rolling deploy)', + function (): void { + $metadataFactory = new EntityMetadataFactory(); + $connection = createSqliteConnection(); + + // Create a table WITHOUT the extender columns (simulates rolling deploy where // the DB schema is still on the old version without profile columns) $connection->execute( - 'CREATE TABLE int_users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL)', - ); - - // Seed a row that has no profile columns at all + 'CREATE TABLE int_users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL)', + ); + + // Seed a row that has no profile columns at all $connection->execute( - 'INSERT INTO int_users (name, email) VALUES (?, ?)', - ['Eve', 'eve@example.com'], - ); - $insertedId = $connection->lastInsertId(); - - // Register entities so metadata knows about the extender + 'INSERT INTO int_users (name, email) VALUES (?, ?)', + ['Eve', 'eve@example.com'], + ); + $insertedId = $connection->lastInsertId(); + + // Register entities so metadata knows about the extender $schemaBuilder = new SchemaBuilder(); - $registry = new SchemaRegistry($metadataFactory, $schemaBuilder); - $registry->registerEntities([IntUser::class, IntUserProfile::class]); - - $hydrator = new EntityHydrator($metadataFactory); - $repository = new IntUserRepository($connection, $metadataFactory, $hydrator); - - /** @var IntUser $user */ - $user = $repository->find($insertedId); - - // Hydration must succeed without error and companion must simply be absent + $registry = new SchemaRegistry($metadataFactory, $schemaBuilder); + $registry->registerEntities([IntUser::class, IntUserProfile::class]); + + $hydrator = new EntityHydrator($metadataFactory); + $repository = new IntUserRepository($connection, $metadataFactory, $hydrator); + + /** @var IntUser $user */ + $user = $repository->find($insertedId); + + // Hydration must succeed without error and companion must simply be absent expect($user)->not->toBeNull() - ->and($user->name)->toBe('Eve') - ->and($user->companion(IntUserProfile::class))->toBeNull(); - }); + ->and($user->name)->toBe('Eve') + ->and($user->companion(IntUserProfile::class))->toBeNull(); + } + ); it('detects column-name conflicts at registration time across two extenders', function (): void { $metadataFactory = new EntityMetadataFactory(); @@ -339,79 +348,85 @@ function buildSchemaAndCreateTable( ]))->toThrow(EntityException::class); }); - it('preserves migrate diff parity by including extender columns in the parent\'s Table value object', function (): void { - // Approach: assert on the merged Table value object (no real DB differ needed). + it( + 'preserves migrate diff parity by including extender columns in the parent\'s Table value object', + function (): void { + // Approach: assert on the merged Table value object (no real DB differ needed). // The merged Table is what SchemaRegistry exposes after registerEntities(), // and it is the same object handed to DiffCalculator — so if the Table has // the extender columns, the differ will produce the correct ADD COLUMN statements. - $metadataFactory = new EntityMetadataFactory(); - $schemaBuilder = new SchemaBuilder(); - $registry = new SchemaRegistry($metadataFactory, $schemaBuilder); - $registry->registerEntities([IntUser::class, IntUserProfile::class]); - - $mergedTable = $registry->getTable('int_users'); - - // Simulate an existing DB table that is missing the extender columns + $metadataFactory = new EntityMetadataFactory(); + $schemaBuilder = new SchemaBuilder(); + $registry = new SchemaRegistry($metadataFactory, $schemaBuilder); + $registry->registerEntities([IntUser::class, IntUserProfile::class]); + + $mergedTable = $registry->getTable('int_users'); + + // Simulate an existing DB table that is missing the extender columns $dbTable = new Table( - name: 'int_users', - columns: [ - new Column(name: 'id', type: 'INTEGER', primaryKey: true, autoIncrement: true), - new Column(name: 'name', type: 'TEXT'), - new Column(name: 'email', type: 'TEXT'), - ], - indexes: [], - ); - - $diffCalculator = new DiffCalculator(); - $diff = $diffCalculator->calculate( - ['int_users' => $mergedTable], - ['int_users' => $dbTable], - ); - - expect($diff->tablesToAlter)->toHaveKey('int_users'); - - $tableDiff = $diff->tablesToAlter['int_users']; - $addedNames = array_map(fn ($col) => $col->name, $tableDiff->columnsToAdd); - - expect($addedNames) - ->toContain('bio') - ->toContain('timezone'); - }); - - it('discovers an extender via EntityDiscovery and registers it correctly into the parent\'s merged schema', function (): void { - $fixturesPath = __DIR__ . '/Fixtures'; - - $classFileParser = new ClassFileParser(); - $discovery = new EntityDiscovery($classFileParser); - - $discovered = $discovery->discoverInPath($fixturesPath); + name: 'int_users', + columns: [ + new Column(name: 'id', type: 'INTEGER', primaryKey: true, autoIncrement: true), + new Column(name: 'name', type: 'TEXT'), + new Column(name: 'email', type: 'TEXT'), + ], + indexes: [], + ); + + $diffCalculator = new DiffCalculator(); + $diff = $diffCalculator->calculate( + ['int_users' => $mergedTable], + ['int_users' => $dbTable], + ); + + expect($diff->tablesToAlter)->toHaveKey('int_users'); + + $tableDiff = $diff->tablesToAlter['int_users']; + $addedNames = array_map(fn ($col) => $col->name, $tableDiff->columnsToAdd); + + expect($addedNames) + ->toContain('bio') + ->toContain('timezone'); + } + ); - // All three fixture classes live in the Fixtures directory + it( + 'discovers an extender via EntityDiscovery and registers it correctly into the parent\'s merged schema', + function (): void { + $fixturesPath = __DIR__ . '/Fixtures'; + + $classFileParser = new ClassFileParser(); + $discovery = new EntityDiscovery($classFileParser); + + $discovered = $discovery->discoverInPath($fixturesPath); + + // All three fixture classes live in the Fixtures directory expect($discovered)->toContain(IntUser::class) - ->and($discovered)->toContain(IntUserProfile::class); - - // Register the discovered set (parent + extender only, exclude conflict fixture) + ->and($discovered)->toContain(IntUserProfile::class); + + // Register the discovered set (parent + extender only, exclude conflict fixture) $filteredClasses = array_values(array_filter( - $discovered, - fn (string $class) => $class !== IntUserSettings::class, - )); - - $metadataFactory = new EntityMetadataFactory(); - $schemaBuilder = new SchemaBuilder(); - $registry = new SchemaRegistry($metadataFactory, $schemaBuilder); - $registry->registerEntities($filteredClasses); - - $table = $registry->getTable('int_users'); - $columnNames = array_map(fn ($col) => $col->name, $table->columns); - - expect($columnNames) - ->toContain('id') - ->toContain('name') - ->toContain('email') - ->toContain('bio') - ->toContain('timezone'); - }); + $discovered, + fn (string $class) => $class !== IntUserSettings::class, + )); + + $metadataFactory = new EntityMetadataFactory(); + $schemaBuilder = new SchemaBuilder(); + $registry = new SchemaRegistry($metadataFactory, $schemaBuilder); + $registry->registerEntities($filteredClasses); + + $table = $registry->getTable('int_users'); + $columnNames = array_map(fn ($col) => $col->name, $table->columns); + + expect($columnNames) + ->toContain('id') + ->toContain('name') + ->toContain('email') + ->toContain('bio') + ->toContain('timezone'); + } + ); it('raises a loud error when constructing a Repository against an extender class', function (): void { $metadataFactory = new EntityMetadataFactory(); diff --git a/packages/database/tests/KnownDriversValidationTest.php b/packages/database/tests/KnownDriversValidationTest.php index e05620aa..3f32e2a1 100644 --- a/packages/database/tests/KnownDriversValidationTest.php +++ b/packages/database/tests/KnownDriversValidationTest.php @@ -7,9 +7,12 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all database drivers', function () use ($knownDriversPath, $skeletonComposerPath): void { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); -}); +test( + 'skeleton suggest block contains all database drivers', + function () use ($knownDriversPath, $skeletonComposerPath): void { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); + } +); test('every database driver follows marko slash prefix pattern', function () use ($knownDriversPath): void { KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); diff --git a/packages/database/tests/Query/QueryBuilderInterfaceTest.php b/packages/database/tests/Query/QueryBuilderInterfaceTest.php index 310ee5dd..1b5cb8f5 100644 --- a/packages/database/tests/Query/QueryBuilderInterfaceTest.php +++ b/packages/database/tests/Query/QueryBuilderInterfaceTest.php @@ -290,10 +290,10 @@ function (): void { $reflection = new ReflectionClass(QueryBuilderInterface::class); $method = $reflection->getMethod('selectRaw'); $params = $method->getParameters(); - + expect($params[0]->getName())->toBe('expression') ->and($params[0]->getType()?->getName())->toBe('string'); - } + }, ); it( @@ -302,12 +302,12 @@ function (): void { $reflection = new ReflectionClass(QueryBuilderInterface::class); $method = $reflection->getMethod('selectRaw'); $params = $method->getParameters(); - + expect($params[1]->getName())->toBe('bindings') ->and($params[1]->getType()?->getName())->toBe('array') ->and($params[1]->isDefaultValueAvailable())->toBeTrue() ->and($params[1]->getDefaultValue())->toBeEmpty(); - } + }, ); it('QueryBuilderInterface::selectRaw returns static', function (): void { @@ -330,10 +330,10 @@ function (): void { $reflection = new ReflectionClass(QueryBuilderInterface::class); $method = $reflection->getMethod('whereRaw'); $params = $method->getParameters(); - + expect($params[0]->getName())->toBe('expression') ->and($params[0]->getType()?->getName())->toBe('string'); - } + }, ); it( @@ -342,12 +342,12 @@ function (): void { $reflection = new ReflectionClass(QueryBuilderInterface::class); $method = $reflection->getMethod('whereRaw'); $params = $method->getParameters(); - + expect($params[1]->getName())->toBe('bindings') ->and($params[1]->getType()?->getName())->toBe('array') ->and($params[1]->isDefaultValueAvailable())->toBeTrue() ->and($params[1]->getDefaultValue())->toBeEmpty(); - } + }, ); it('QueryBuilderInterface::whereRaw returns static', function (): void { @@ -370,10 +370,10 @@ function (): void { $reflection = new ReflectionClass(QueryBuilderInterface::class); $method = $reflection->getMethod('orderByRaw'); $params = $method->getParameters(); - + expect($params[0]->getName())->toBe('expression') ->and($params[0]->getType()?->getName())->toBe('string'); - } + }, ); it( @@ -382,12 +382,12 @@ function (): void { $reflection = new ReflectionClass(QueryBuilderInterface::class); $method = $reflection->getMethod('orderByRaw'); $params = $method->getParameters(); - + expect($params[1]->getName())->toBe('direction') ->and($params[1]->getType()?->getName())->toBe('string') ->and($params[1]->isDefaultValueAvailable())->toBeTrue() ->and($params[1]->getDefaultValue())->toBe('ASC'); - } + }, ); it('QueryBuilderInterface::orderByRaw returns static', function (): void { diff --git a/packages/database/tests/Query/QuerySpecificationTest.php b/packages/database/tests/Query/QuerySpecificationTest.php index 43c842cb..1d874ab6 100644 --- a/packages/database/tests/Query/QuerySpecificationTest.php +++ b/packages/database/tests/Query/QuerySpecificationTest.php @@ -75,7 +75,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -230,7 +233,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -314,7 +320,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -469,7 +478,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } diff --git a/packages/database/tests/Query/SpecEagerLoadCompositionTest.php b/packages/database/tests/Query/SpecEagerLoadCompositionTest.php index 526c3b46..38954287 100644 --- a/packages/database/tests/Query/SpecEagerLoadCompositionTest.php +++ b/packages/database/tests/Query/SpecEagerLoadCompositionTest.php @@ -126,14 +126,21 @@ public function distinct(): static return $this; } - public function where(string $column, string $operator, mixed $value): static + public function where( + string $column, + string $operator, + mixed $value, + ): static { $this->wheresCalled[] = "$column $operator $value"; return $this; } - public function whereIn(string $column, array $values): static + public function whereIn( + string $column, + array $values, + ): static { return $this; } @@ -148,7 +155,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -163,42 +173,73 @@ public function whereJsonMissing(string $path): static return $this; } - public function orWhere(string $column, string $operator, mixed $value): static + public function orWhere( + string $column, + string $operator, + mixed $value, + ): static { return $this; } - public function join(string $table, string $first, string $operator, string $second): static + public function join( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function leftJoin(string $table, string $first, string $operator, string $second): static + public function leftJoin( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function rightJoin(string $table, string $first, string $operator, string $second): static + public function rightJoin( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function orderBy(string $column, string $direction = 'ASC'): static + public function orderBy( + string $column, + string $direction = 'ASC', + ): static { return $this; } - public function orderByRaw(string $expression, string $direction = 'ASC'): static + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { return $this; } - public function selectRaw(string $expression, array $bindings = []): static + public function selectRaw( + string $expression, + array $bindings = [], + ): static { return $this; } - public function whereRaw(string $expression, array $bindings = []): static + public function whereRaw( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -268,7 +309,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -293,7 +337,10 @@ public function avg(string $column): int|float|null return null; } - public function raw(string $sql, array $bindings = []): array + public function raw( + string $sql, + array $bindings = [], + ): array { return []; } @@ -326,12 +373,19 @@ public function distinct(): static return $this; } - public function where(string $column, string $operator, mixed $value): static + public function where( + string $column, + string $operator, + mixed $value, + ): static { return $this; } - public function whereIn(string $column, array $values): static + public function whereIn( + string $column, + array $values, + ): static { $this->whereInCount++; @@ -348,7 +402,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -363,42 +420,73 @@ public function whereJsonMissing(string $path): static return $this; } - public function orWhere(string $column, string $operator, mixed $value): static + public function orWhere( + string $column, + string $operator, + mixed $value, + ): static { return $this; } - public function join(string $table, string $first, string $operator, string $second): static + public function join( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function leftJoin(string $table, string $first, string $operator, string $second): static + public function leftJoin( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function rightJoin(string $table, string $first, string $operator, string $second): static + public function rightJoin( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function orderBy(string $column, string $direction = 'ASC'): static + public function orderBy( + string $column, + string $direction = 'ASC', + ): static { return $this; } - public function orderByRaw(string $expression, string $direction = 'ASC'): static + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { return $this; } - public function selectRaw(string $expression, array $bindings = []): static + public function selectRaw( + string $expression, + array $bindings = [], + ): static { return $this; } - public function whereRaw(string $expression, array $bindings = []): static + public function whereRaw( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -468,7 +556,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -493,7 +584,10 @@ public function avg(string $column): int|float|null return null; } - public function raw(string $sql, array $bindings = []): array + public function raw( + string $sql, + array $bindings = [], + ): array { return []; } @@ -515,12 +609,18 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { return $this->rows; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { return 0; } @@ -618,12 +718,19 @@ public function distinct(): static return $this; } - public function where(string $column, string $operator, mixed $value): static + public function where( + string $column, + string $operator, + mixed $value, + ): static { return $this; } - public function whereIn(string $column, array $values): static + public function whereIn( + string $column, + array $values, + ): static { return $this; } @@ -638,7 +745,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -653,42 +763,73 @@ public function whereJsonMissing(string $path): static return $this; } - public function orWhere(string $column, string $operator, mixed $value): static + public function orWhere( + string $column, + string $operator, + mixed $value, + ): static { return $this; } - public function join(string $table, string $first, string $operator, string $second): static + public function join( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function leftJoin(string $table, string $first, string $operator, string $second): static + public function leftJoin( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function rightJoin(string $table, string $first, string $operator, string $second): static + public function rightJoin( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function orderBy(string $column, string $direction = 'ASC'): static + public function orderBy( + string $column, + string $direction = 'ASC', + ): static { return $this; } - public function orderByRaw(string $expression, string $direction = 'ASC'): static + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { return $this; } - public function selectRaw(string $expression, array $bindings = []): static + public function selectRaw( + string $expression, + array $bindings = [], + ): static { return $this; } - public function whereRaw(string $expression, array $bindings = []): static + public function whereRaw( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -758,7 +899,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -783,7 +927,10 @@ public function avg(string $column): int|float|null return null; } - public function raw(string $sql, array $bindings = []): array + public function raw( + string $sql, + array $bindings = [], + ): array { return []; } @@ -882,51 +1029,57 @@ public function apply(EntityQueryBuilderInterface $builder): void expect($countingBuilder->whereInCount)->toBe(1); }); -it('still supports explicit $repo->with(...)->matching(...) callers (fixes pre-existing bug where matching() passed the raw inner builder)', function (): void { - $postRows = [['id' => 1, 'title' => 'Hello', 'status' => 'published', 'author_id' => 5]]; - $authorRows = [['id' => 5, 'name' => 'Alice', 'author_id' => 5]]; - - $primaryBuilder = makeSpecStubBuilder($postRows); - $relatedBuilder = makeSpecStubBuilder($authorRows); - $loader = makeSpecLoader($relatedBuilder); - $repo = makeSpecRepository(loader: $loader, primaryBuilder: $primaryBuilder); - - // Plain spec that applies no eager load — eager load comes from call-site with() +it( + 'still supports explicit $repo->with(...)->matching(...) callers (fixes pre-existing bug where matching() passed the raw inner builder)', + function (): void { + $postRows = [['id' => 1, 'title' => 'Hello', 'status' => 'published', 'author_id' => 5]]; + $authorRows = [['id' => 5, 'name' => 'Alice', 'author_id' => 5]]; + + $primaryBuilder = makeSpecStubBuilder($postRows); + $relatedBuilder = makeSpecStubBuilder($authorRows); + $loader = makeSpecLoader($relatedBuilder); + $repo = makeSpecRepository(loader: $loader, primaryBuilder: $primaryBuilder); + + // Plain spec that applies no eager load — eager load comes from call-site with() $spec = new class () implements QuerySpecification - { - public function apply(EntityQueryBuilderInterface $builder): void - { - $builder->where('status', '=', 'published'); - } - }; - - $collection = $repo->with('author')->matching($spec); - - expect($collection->count())->toBe(1) - ->and($collection->first()->author)->toBeInstanceOf(SpecAuthor::class); -}); - -it('merges call-site $repo->with(...) relationships with spec-declared with() relationships without duplicates', function (): void { - $postRows = [['id' => 1, 'title' => 'Hello', 'status' => 'published', 'author_id' => 5]]; - - $countingBuilder = makeCountingBuilder([['id' => 5, 'name' => 'Alice', 'author_id' => 5]]); - $primaryBuilder = makeSpecStubBuilder($postRows); - $loader = makeSpecLoader($countingBuilder); - $repo = makeSpecRepository(loader: $loader, primaryBuilder: $primaryBuilder); - - $spec = new class () implements QuerySpecification - { - public function apply(EntityQueryBuilderInterface $builder): void { - $builder->with('author'); - } - }; - - // Both call-site and spec declare 'author' — should issue only one query + public function apply(EntityQueryBuilderInterface $builder): void + { + $builder->where('status', '=', 'published'); + } + }; + + $collection = $repo->with('author')->matching($spec); + + expect($collection->count())->toBe(1) + ->and($collection->first()->author)->toBeInstanceOf(SpecAuthor::class); + } +); + +it( + 'merges call-site $repo->with(...) relationships with spec-declared with() relationships without duplicates', + function (): void { + $postRows = [['id' => 1, 'title' => 'Hello', 'status' => 'published', 'author_id' => 5]]; + + $countingBuilder = makeCountingBuilder([['id' => 5, 'name' => 'Alice', 'author_id' => 5]]); + $primaryBuilder = makeSpecStubBuilder($postRows); + $loader = makeSpecLoader($countingBuilder); + $repo = makeSpecRepository(loader: $loader, primaryBuilder: $primaryBuilder); + + $spec = new class () implements QuerySpecification + { + public function apply(EntityQueryBuilderInterface $builder): void + { + $builder->with('author'); + } + }; + + // Both call-site and spec declare 'author' — should issue only one query $repo->with('author')->matching($spec); - - expect($countingBuilder->whereInCount)->toBe(1); -}); + + expect($countingBuilder->whereInCount)->toBe(1); + } +); it('does not execute N+1 queries when a spec declares eager loads', function (): void { // 3 posts, all with author_id = 5 @@ -956,36 +1109,42 @@ public function apply(EntityQueryBuilderInterface $builder): void ->and($countingBuilder->whereInCount)->toBe(1); }); -it('validates each spec-declared relationship name against entity metadata and throws on unknown names (consistent with Repository::with())', function (): void { - $postRows = [['id' => 1, 'title' => 'Hello', 'status' => 'published', 'author_id' => 5]]; - $primaryBuilder = makeSpecStubBuilder($postRows); - $loader = makeSpecLoader(makeSpecStubBuilder()); - $repo = makeSpecRepository(loader: $loader, primaryBuilder: $primaryBuilder); - +it( + 'validates each spec-declared relationship name against entity metadata and throws on unknown names (consistent with Repository::with())', + function (): void { + $postRows = [['id' => 1, 'title' => 'Hello', 'status' => 'published', 'author_id' => 5]]; + $primaryBuilder = makeSpecStubBuilder($postRows); + $loader = makeSpecLoader(makeSpecStubBuilder()); + $repo = makeSpecRepository(loader: $loader, primaryBuilder: $primaryBuilder); + + $spec = new class () implements QuerySpecification + { + public function apply(EntityQueryBuilderInterface $builder): void + { + $builder->with('nonExistentRelationship'); + } + }; + + expect(fn () => $repo->matching($spec))->toThrow(RepositoryException::class); + } +); + +it( + 'existing single-method QuerySpecification implementations continue to compile after updating only the apply() parameter type hint', + function (): void { + // A spec using the new signature — verifies backward-compatible refactor $spec = new class () implements QuerySpecification - { - public function apply(EntityQueryBuilderInterface $builder): void { - $builder->with('nonExistentRelationship'); - } - }; - - expect(fn () => $repo->matching($spec))->toThrow(RepositoryException::class); -}); - -it('existing single-method QuerySpecification implementations continue to compile after updating only the apply() parameter type hint', function (): void { - // A spec using the new signature — verifies backward-compatible refactor - $spec = new class () implements QuerySpecification - { - public function apply(EntityQueryBuilderInterface $builder): void - { - $builder->where('status', '=', 'active'); - } - }; - - $reflection = new ReflectionClass($spec); - $method = $reflection->getMethod('apply'); - $params = $method->getParameters(); - - expect($params[0]->getType()?->getName())->toBe(EntityQueryBuilderInterface::class); -}); + public function apply(EntityQueryBuilderInterface $builder): void + { + $builder->where('status', '=', 'active'); + } + }; + + $reflection = new ReflectionClass($spec); + $method = $reflection->getMethod('apply'); + $params = $method->getParameters(); + + expect($params[0]->getType()?->getName())->toBe(EntityQueryBuilderInterface::class); + } +); diff --git a/packages/database/tests/Repository/RepositoryBatchInsertTest.php b/packages/database/tests/Repository/RepositoryBatchInsertTest.php index a8f9206f..2b194f43 100644 --- a/packages/database/tests/Repository/RepositoryBatchInsertTest.php +++ b/packages/database/tests/Repository/RepositoryBatchInsertTest.php @@ -120,14 +120,20 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { $this->sqlLog[] = ['type' => 'query', 'sql' => $sql, 'bindings' => $bindings]; return []; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { if ($this->shouldThrow) { $this->shouldThrow = false; @@ -174,12 +180,18 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { return []; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { if ($this->failInsert && str_contains($sql, 'INSERT')) { throw new RuntimeException('Simulated DB failure on INSERT'); @@ -281,7 +293,13 @@ public function dispatch(Event $event): void $sqlLog = []; $connection = makeBatchSpyConnection($sqlLog, 1); $dispatcher = new BatchFakeDispatcher(); - $repository = new BatchUserRepository($connection, new EntityMetadataFactory(), new EntityHydrator(), null, $dispatcher); + $repository = new BatchUserRepository( + $connection, + new EntityMetadataFactory(), + new EntityHydrator(), + null, + $dispatcher + ); $user1 = new BatchUser(); $user1->name = 'Alice'; @@ -303,7 +321,13 @@ public function dispatch(Event $event): void $sqlLog = []; $connection = makeBatchSpyConnection($sqlLog, 1); $dispatcher = new BatchFakeDispatcher(); - $repository = new BatchUserRepository($connection, new EntityMetadataFactory(), new EntityHydrator(), null, $dispatcher); + $repository = new BatchUserRepository( + $connection, + new EntityMetadataFactory(), + new EntityHydrator(), + null, + $dispatcher + ); $user1 = new BatchUser(); $user1->name = 'Alice'; @@ -327,37 +351,42 @@ public function dispatch(Event $event): void expect(max($creatingIdx) < min($createdIdx))->toBeTrue(); }); -it('populates auto-generated primary keys back onto each entity when the driver supports it (MySQL: lastInsertId returns the FIRST id, increment by one per row assuming no gaps; PostgreSQL: use INSERT ... RETURNING id)', function (): void { - $sqlLog = []; - // Simulate MySQL: lastInsertId() returns first inserted ID = 10 +it( + 'populates auto-generated primary keys back onto each entity when the driver supports it (MySQL: lastInsertId returns the FIRST id, increment by one per row assuming no gaps; PostgreSQL: use INSERT ... RETURNING id)', + function (): void { + $sqlLog = []; + // Simulate MySQL: lastInsertId() returns first inserted ID = 10 $connection = makeBatchSpyConnection($sqlLog, 10); - $repository = new BatchUserRepository($connection, new EntityMetadataFactory(), new EntityHydrator()); - - $user1 = new BatchUser(); - $user1->name = 'Alice'; - $user1->email = 'alice@example.com'; - - $user2 = new BatchUser(); - $user2->name = 'Bob'; - $user2->email = 'bob@example.com'; - - $user3 = new BatchUser(); - $user3->name = 'Carol'; - $user3->email = 'carol@example.com'; - - expect($user1->id)->toBeNull() - ->and($user2->id)->toBeNull() - ->and($user3->id)->toBeNull(); - - $repository->insertBatch([$user1, $user2, $user3]); - - expect($user1->id)->toBe(10) - ->and($user2->id)->toBe(11) - ->and($user3->id)->toBe(12); -}); + $repository = new BatchUserRepository($connection, new EntityMetadataFactory(), new EntityHydrator()); + + $user1 = new BatchUser(); + $user1->name = 'Alice'; + $user1->email = 'alice@example.com'; + + $user2 = new BatchUser(); + $user2->name = 'Bob'; + $user2->email = 'bob@example.com'; + + $user3 = new BatchUser(); + $user3->name = 'Carol'; + $user3->email = 'carol@example.com'; + + expect($user1->id)->toBeNull() + ->and($user2->id)->toBeNull() + ->and($user3->id)->toBeNull(); + + $repository->insertBatch([$user1, $user2, $user3]); + + expect($user1->id)->toBe(10) + ->and($user2->id)->toBe(11) + ->and($user3->id)->toBe(12); + } +); -it('documents and tests that MySQL populated-id logic is correct only when innodb_autoinc_lock_mode permits sequential ids (contiguous block)', function (): void { - // MySQL innodb_autoinc_lock_mode=2 (interleaved, the default since MySQL 8.0) does NOT +it( + 'documents and tests that MySQL populated-id logic is correct only when innodb_autoinc_lock_mode permits sequential ids (contiguous block)', + function (): void { + // MySQL innodb_autoinc_lock_mode=2 (interleaved, the default since MySQL 8.0) does NOT // guarantee a contiguous block of IDs for a single multi-row INSERT in a concurrent // environment. The MySQL id-recovery strategy (LAST_INSERT_ID + row-count math) is // therefore only reliable under lock_mode=0 (traditional) or lock_mode=1 (consecutive), @@ -366,30 +395,31 @@ public function dispatch(Event $event): void // This test verifies the documented contract: given a contiguous block starting at // firstId, each entity receives firstId + its zero-based index in the batch. - $sqlLog = []; - // firstId=5 simulates a scenario where rows 5, 6, 7 are a contiguous block + $sqlLog = []; + // firstId=5 simulates a scenario where rows 5, 6, 7 are a contiguous block $connection = makeBatchSpyConnection($sqlLog, 5); - $repository = new BatchUserRepository($connection, new EntityMetadataFactory(), new EntityHydrator()); - - $users = []; - for ($i = 0; $i < 3; $i++) { - $u = new BatchUser(); - $u->name = "User $i"; - $u->email = "user$i@example.com"; - $users[] = $u; - } - - $repository->insertBatch($users); - - // Under contiguous-block assumption: IDs are 5, 6, 7 + $repository = new BatchUserRepository($connection, new EntityMetadataFactory(), new EntityHydrator()); + + $users = []; + for ($i = 0; $i < 3; $i++) { + $u = new BatchUser(); + $u->name = "User $i"; + $u->email = "user$i@example.com"; + $users[] = $u; + } + + $repository->insertBatch($users); + + // Under contiguous-block assumption: IDs are 5, 6, 7 expect($users[0]->id)->toBe(5) - ->and($users[1]->id)->toBe(6) - ->and($users[2]->id)->toBe(7); - - // Verify that only a single INSERT statement was issued + ->and($users[1]->id)->toBe(6) + ->and($users[2]->id)->toBe(7); + + // Verify that only a single INSERT statement was issued $insertStmts = array_values(array_filter($sqlLog, fn ($e) => str_contains($e['sql'], 'INSERT'))); - expect($insertStmts)->toHaveCount(1); -}); + expect($insertStmts)->toHaveCount(1); + } +); it('throws a descriptive exception when the input array is empty', function (): void { $sqlLog = []; diff --git a/packages/database/tests/Repository/RepositoryLifecycleEventTest.php b/packages/database/tests/Repository/RepositoryLifecycleEventTest.php index 2e7e779a..b97a8c37 100644 --- a/packages/database/tests/Repository/RepositoryLifecycleEventTest.php +++ b/packages/database/tests/Repository/RepositoryLifecycleEventTest.php @@ -65,12 +65,18 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { return []; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { return 1; } @@ -102,7 +108,10 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { if ($this->firstQuery) { $this->firstQuery = false; @@ -115,7 +124,10 @@ public function query(string $sql, array $bindings = []): array return []; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { return 1; } @@ -147,7 +159,10 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { if ($this->firstQuery) { $this->firstQuery = false; @@ -160,7 +175,10 @@ public function query(string $sql, array $bindings = []): array return []; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { return 1; } diff --git a/packages/database/tests/Repository/RepositoryMatchingTest.php b/packages/database/tests/Repository/RepositoryMatchingTest.php index 44e73406..feeca552 100644 --- a/packages/database/tests/Repository/RepositoryMatchingTest.php +++ b/packages/database/tests/Repository/RepositoryMatchingTest.php @@ -91,7 +91,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -246,7 +249,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } diff --git a/packages/database/tests/Repository/RepositoryQueryBuilderEnhancedTest.php b/packages/database/tests/Repository/RepositoryQueryBuilderEnhancedTest.php index d388a5dc..0487fce6 100644 --- a/packages/database/tests/Repository/RepositoryQueryBuilderEnhancedTest.php +++ b/packages/database/tests/Repository/RepositoryQueryBuilderEnhancedTest.php @@ -141,8 +141,7 @@ public function whereNotNull(string $column): static public function whereJsonContains( string $path, mixed $value, - ): static - { + ): static { return $this; } @@ -319,8 +318,7 @@ public function groupBy(string ...$columns): static public function having( string $expression, array $bindings = [], - ): static - { + ): static { return $this; } @@ -651,14 +649,14 @@ public function apply(QueryBuilderInterface $builder): void function (): void { $stub = makeRqbStubBuilder([]); $rqb = makeRqb($stub); - + $result = $rqb ->select('id', 'name') ->selectRaw('COALESCE(a, b) AS resolved', [1]) ->where('status', '=', 'active') ->whereRaw('score > ?', [50]) ->orderBy('name', 'ASC'); - + expect($result)->toBeInstanceOf(RepositoryQueryBuilder::class) ->and($stub->selectRawCalled)->toBe([ ['expression' => 'COALESCE(a, b) AS resolved', 'bindings' => [1]], @@ -668,7 +666,7 @@ function (): void { ['expression' => 'score > ?', 'bindings' => [50]], ]) ->and($stub->orderByCalled)->toBe(['name ASC']); - } + }, ); it( @@ -677,7 +675,7 @@ function (): void { $rows = [['id' => 1, 'name' => 'Alice']]; $stub = makeRqbStubBuilder($rows); $rqb = makeRqb($stub); - + $spec = new class () implements QuerySpecification { public function apply(QueryBuilderInterface $builder): void @@ -685,14 +683,14 @@ public function apply(QueryBuilderInterface $builder): void $builder->selectRaw('COALESCE(a, b) AS resolved', [42]); } }; - + $collection = $rqb->matching($spec); - + expect($collection)->toBeInstanceOf(EntityCollection::class) ->and($stub->selectRawCalled)->toBe([ ['expression' => 'COALESCE(a, b) AS resolved', 'bindings' => [42]], ]); - } + }, ); it( @@ -701,7 +699,7 @@ function (): void { $rows = [['id' => 1, 'name' => 'Alice']]; $stub = makeRqbStubBuilder($rows); $rqb = makeRqb($stub); - + $spec = new class () implements QuerySpecification { public function apply(QueryBuilderInterface $builder): void @@ -709,12 +707,12 @@ public function apply(QueryBuilderInterface $builder): void $builder->whereRaw('score > ?', [50]); } }; - + $collection = $rqb->matching($spec); - + expect($collection)->toBeInstanceOf(EntityCollection::class) ->and($stub->whereRawCalled)->toBe([ ['expression' => 'score > ?', 'bindings' => [50]], ]); - } + }, ); diff --git a/packages/database/tests/Repository/RepositoryReturnTypeTest.php b/packages/database/tests/Repository/RepositoryReturnTypeTest.php index c399b6ab..77790716 100644 --- a/packages/database/tests/Repository/RepositoryReturnTypeTest.php +++ b/packages/database/tests/Repository/RepositoryReturnTypeTest.php @@ -52,12 +52,18 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { return $this->queryResult; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { return 1; } diff --git a/packages/database/tests/Repository/RepositoryTest.php b/packages/database/tests/Repository/RepositoryTest.php index 23a0794b..f7cd1155 100644 --- a/packages/database/tests/Repository/RepositoryTest.php +++ b/packages/database/tests/Repository/RepositoryTest.php @@ -1143,7 +1143,10 @@ public function whereNotNull( return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -1309,7 +1312,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -1481,7 +1487,10 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { $this->sqlLog[] = ['sql' => $sql, 'bindings' => $bindings]; $result = $this->queryResults[$this->queryIndex] ?? []; @@ -1490,7 +1499,10 @@ public function query(string $sql, array $bindings = []): array return $result; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { $this->sqlLog[] = ['sql' => $sql, 'bindings' => $bindings]; @@ -1524,7 +1536,11 @@ class OrderRepository extends Repository { protected const string ENTITY_CLASS = OrderWithCustomPk::class; - public function exposeIsColumnUnique(string $column, mixed $value, ?int $excludeId = null): bool + public function exposeIsColumnUnique( + string $column, + mixed $value, + ?int $excludeId = null, + ): bool { return $this->isColumnUnique($column, $value, $excludeId); } @@ -1574,16 +1590,19 @@ public function exposeIsColumnUnique(string $column, mixed $value, ?int $exclude ->and($deleteSql[0]['sql'])->not->toContain('WHERE order_id = ?'); }); -it('it no longer hardcodes \'id\' in Repository::isColumnUnique exclude clause — uses the real PK column', function (): void { - $sqlLog = []; - $connection = createSpyConnection($sqlLog, [[]], [[]]); - $repository = new OrderRepository($connection, new EntityMetadataFactory(), new EntityHydrator()); - - $repository->exposeIsColumnUnique('status', 'shipped', 42); - - expect($sqlLog[0]['sql'])->toContain('AND order_uuid != ?') - ->and($sqlLog[0]['sql'])->not->toContain('AND status != ?'); -}); +it( + 'it no longer hardcodes \'id\' in Repository::isColumnUnique exclude clause — uses the real PK column', + function (): void { + $sqlLog = []; + $connection = createSpyConnection($sqlLog, [[]], [[]]); + $repository = new OrderRepository($connection, new EntityMetadataFactory(), new EntityHydrator()); + + $repository->exposeIsColumnUnique('status', 'shipped', 42); + + expect($sqlLog[0]['sql'])->toContain('AND order_uuid != ?') + ->and($sqlLog[0]['sql'])->not->toContain('AND status != ?'); + } +); it('continues to work for entities that DO declare a primary key explicitly', function (): void { $connection = createMockConnection([ @@ -1699,12 +1718,18 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { return []; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { $this->sqlLog[] = ['sql' => $sql, 'bindings' => $bindings]; @@ -1742,29 +1767,32 @@ public function lastInsertId(): int ->and($sqlLog[0]['sql'])->not->toContain('website'); }); - it('inserts a parent entity with one attached companion using merged columns in a single INSERT', function (): void { - $sqlLog = []; - $connection = createAccountSpyConnection($sqlLog); - $metadataFactory = new EntityMetadataFactory(); - $hydrator = new EntityHydrator($metadataFactory); - $repository = new AccountRepository($connection, $metadataFactory, $hydrator); - - $account = new RepositoryTestAccount(); - $account->username = 'bob'; - - $profile = new RepositoryTestAccountProfile(); - $profile->bio = 'Hello'; - $profile->website = 'https://example.com'; - $account->attachCompanion($profile); - - $repository->save($account); - - expect($sqlLog)->toHaveCount(1) - ->and($sqlLog[0]['sql'])->toContain('INSERT INTO accounts') - ->and($sqlLog[0]['sql'])->toContain('username') - ->and($sqlLog[0]['sql'])->toContain('bio') - ->and($sqlLog[0]['sql'])->toContain('website'); - }); + it( + 'inserts a parent entity with one attached companion using merged columns in a single INSERT', + function (): void { + $sqlLog = []; + $connection = createAccountSpyConnection($sqlLog); + $metadataFactory = new EntityMetadataFactory(); + $hydrator = new EntityHydrator($metadataFactory); + $repository = new AccountRepository($connection, $metadataFactory, $hydrator); + + $account = new RepositoryTestAccount(); + $account->username = 'bob'; + + $profile = new RepositoryTestAccountProfile(); + $profile->bio = 'Hello'; + $profile->website = 'https://example.com'; + $account->attachCompanion($profile); + + $repository->save($account); + + expect($sqlLog)->toHaveCount(1) + ->and($sqlLog[0]['sql'])->toContain('INSERT INTO accounts') + ->and($sqlLog[0]['sql'])->toContain('username') + ->and($sqlLog[0]['sql'])->toContain('bio') + ->and($sqlLog[0]['sql'])->toContain('website'); + } + ); it('inserts a parent entity with multiple attached companions using all merged columns', function (): void { $sqlLog = []; @@ -2018,58 +2046,67 @@ public function lastInsertId(): int expect($sqlLog)->toBeEmpty(); }); - it('inserts a parent with a freshly-attached (never-hydrated) companion and registers original values on the companion after INSERT', function (): void { - $sqlLog = []; - $connection = createAccountSpyConnection($sqlLog, lastInsertId: 7); - $metadataFactory = new EntityMetadataFactory(); - $hydrator = new EntityHydrator($metadataFactory); - $repository = new AccountRepository($connection, $metadataFactory, $hydrator); - - $account = new RepositoryTestAccount(); - $account->username = 'leo'; - - $profile = new RepositoryTestAccountProfile(); - $profile->bio = 'Fresh bio'; - $profile->website = 'https://leo.dev'; - $account->attachCompanion($profile); - - $repository->save($account); - - // The companion was freshly constructed — after INSERT it must have + it( + 'inserts a parent with a freshly-attached (never-hydrated) companion and registers original values on the companion after INSERT', + function (): void { + $sqlLog = []; + $connection = createAccountSpyConnection($sqlLog, lastInsertId: 7); + $metadataFactory = new EntityMetadataFactory(); + $hydrator = new EntityHydrator($metadataFactory); + $repository = new AccountRepository($connection, $metadataFactory, $hydrator); + + $account = new RepositoryTestAccount(); + $account->username = 'leo'; + + $profile = new RepositoryTestAccountProfile(); + $profile->bio = 'Fresh bio'; + $profile->website = 'https://leo.dev'; + $account->attachCompanion($profile); + + $repository->save($account); + + // The companion was freshly constructed — after INSERT it must have // originalValues so subsequent updates diff correctly. $originalValues = $hydrator->getOriginalValues($profile); - - expect($originalValues)->not->toBeEmpty() - ->and($originalValues['bio'])->toBe('Fresh bio') - ->and($originalValues['website'])->toBe('https://leo.dev'); - }); - - it('throws RepositoryException when constructing a Repository whose ENTITY_CLASS is an extender', function (): void { - $ignored = []; - $connection = createAccountSpyConnection($ignored); - $metadataFactory = new EntityMetadataFactory(); - $hydrator = new EntityHydrator($metadataFactory); - - expect(fn () => new ExtenderRepository($connection, $metadataFactory, $hydrator)) - ->toThrow(RepositoryException::class, 'has no primary key of its own'); - }); - - it('throws BatchInsertException when insertBatch is called with entities that have companions attached', function (): void { - $ignored = []; - $connection = createAccountSpyConnection($ignored); - $metadataFactory = new EntityMetadataFactory(); - $hydrator = new EntityHydrator($metadataFactory); - $repository = new AccountRepository($connection, $metadataFactory, $hydrator); - - $account = new RepositoryTestAccount(); - $account->username = 'mike'; - - $profile = new RepositoryTestAccountProfile(); - $profile->bio = 'Bio'; - $profile->website = 'https://mike.io'; - $account->attachCompanion($profile); - - expect(fn () => $repository->insertBatch([$account])) - ->toThrow(BatchInsertException::class, 'companions'); - }); + + expect($originalValues)->not->toBeEmpty() + ->and($originalValues['bio'])->toBe('Fresh bio') + ->and($originalValues['website'])->toBe('https://leo.dev'); + } + ); + + it( + 'throws RepositoryException when constructing a Repository whose ENTITY_CLASS is an extender', + function (): void { + $ignored = []; + $connection = createAccountSpyConnection($ignored); + $metadataFactory = new EntityMetadataFactory(); + $hydrator = new EntityHydrator($metadataFactory); + + expect(fn () => new ExtenderRepository($connection, $metadataFactory, $hydrator)) + ->toThrow(RepositoryException::class, 'has no primary key of its own'); + } + ); + + it( + 'throws BatchInsertException when insertBatch is called with entities that have companions attached', + function (): void { + $ignored = []; + $connection = createAccountSpyConnection($ignored); + $metadataFactory = new EntityMetadataFactory(); + $hydrator = new EntityHydrator($metadataFactory); + $repository = new AccountRepository($connection, $metadataFactory, $hydrator); + + $account = new RepositoryTestAccount(); + $account->username = 'mike'; + + $profile = new RepositoryTestAccountProfile(); + $profile->bio = 'Bio'; + $profile->website = 'https://mike.io'; + $account->attachCompanion($profile); + + expect(fn () => $repository->insertBatch([$account])) + ->toThrow(BatchInsertException::class, 'companions'); + } + ); }); diff --git a/packages/database/tests/Repository/RepositoryWithTest.php b/packages/database/tests/Repository/RepositoryWithTest.php index 73a67872..265c7e18 100644 --- a/packages/database/tests/Repository/RepositoryWithTest.php +++ b/packages/database/tests/Repository/RepositoryWithTest.php @@ -140,7 +140,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -295,7 +298,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -608,7 +614,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -763,7 +772,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -871,7 +883,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -1026,7 +1041,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -1127,7 +1145,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -1282,7 +1303,10 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } diff --git a/packages/database/tests/Repository/StringPrimaryKeyTest.php b/packages/database/tests/Repository/StringPrimaryKeyTest.php index 3bfe7185..52223f25 100644 --- a/packages/database/tests/Repository/StringPrimaryKeyTest.php +++ b/packages/database/tests/Repository/StringPrimaryKeyTest.php @@ -118,7 +118,10 @@ public function isConnected(): bool return true; } - public function query(string $sql, array $bindings = []): array + public function query( + string $sql, + array $bindings = [], + ): array { $result = $this->queryResults[$this->queryIndex] ?? []; $this->queryIndex++; @@ -126,7 +129,10 @@ public function query(string $sql, array $bindings = []): array return $result; } - public function execute(string $sql, array $bindings = []): int + public function execute( + string $sql, + array $bindings = [], + ): int { $this->executedSql[] = $sql; $this->executedBindings[] = $bindings; @@ -165,12 +171,19 @@ public function select(string ...$columns): static return $this; } - public function where(string $column, string $operator, mixed $value): static + public function where( + string $column, + string $operator, + mixed $value, + ): static { return $this; } - public function whereIn(string $column, array $values): static + public function whereIn( + string $column, + array $values, + ): static { $this->capturedWhereIn[] = ['column' => $column, 'values' => $values]; @@ -187,7 +200,10 @@ public function whereNotNull(string $column): static return $this; } - public function whereJsonContains(string $path, mixed $value): static + public function whereJsonContains( + string $path, + mixed $value, + ): static { return $this; } @@ -202,42 +218,73 @@ public function whereJsonMissing(string $path): static return $this; } - public function orWhere(string $column, string $operator, mixed $value): static + public function orWhere( + string $column, + string $operator, + mixed $value, + ): static { return $this; } - public function join(string $table, string $first, string $operator, string $second): static + public function join( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function leftJoin(string $table, string $first, string $operator, string $second): static + public function leftJoin( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function rightJoin(string $table, string $first, string $operator, string $second): static + public function rightJoin( + string $table, + string $first, + string $operator, + string $second, + ): static { return $this; } - public function orderBy(string $column, string $direction = 'ASC'): static + public function orderBy( + string $column, + string $direction = 'ASC', + ): static { return $this; } - public function orderByRaw(string $expression, string $direction = 'ASC'): static + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { return $this; } - public function selectRaw(string $expression, array $bindings = []): static + public function selectRaw( + string $expression, + array $bindings = [], + ): static { return $this; } - public function whereRaw(string $expression, array $bindings = []): static + public function whereRaw( + string $expression, + array $bindings = [], + ): static { return $this; } @@ -332,12 +379,18 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + public function having( + string $expression, + array $bindings = [], + ): static { return $this; } - public function raw(string $sql, array $bindings = []): array + public function raw( + string $sql, + array $bindings = [], + ): array { return []; } @@ -467,9 +520,21 @@ public function create(): QueryBuilderInterface tableName: 'orders', primaryKey: 'uuid', properties: [ - 'uuid' => new PropertyMetadata(name: 'uuid', columnName: 'uuid', type: 'string', nullable: true, isPrimaryKey: true, isAutoIncrement: false), + 'uuid' => new PropertyMetadata( + name: 'uuid', + columnName: 'uuid', + type: 'string', + nullable: true, + isPrimaryKey: true, + isAutoIncrement: false + ), 'status' => new PropertyMetadata(name: 'status', columnName: 'status', type: 'string'), - 'productUuid' => new PropertyMetadata(name: 'productUuid', columnName: 'product_uuid', type: 'string', nullable: true), + 'productUuid' => new PropertyMetadata( + name: 'productUuid', + columnName: 'product_uuid', + type: 'string', + nullable: true + ), ], relationships: [ 'product' => new RelationshipMetadata( @@ -518,9 +583,21 @@ public function create(): QueryBuilderInterface tableName: 'orders', primaryKey: 'uuid', properties: [ - 'uuid' => new PropertyMetadata(name: 'uuid', columnName: 'uuid', type: 'string', nullable: true, isPrimaryKey: true, isAutoIncrement: false), + 'uuid' => new PropertyMetadata( + name: 'uuid', + columnName: 'uuid', + type: 'string', + nullable: true, + isPrimaryKey: true, + isAutoIncrement: false + ), 'status' => new PropertyMetadata(name: 'status', columnName: 'status', type: 'string'), - 'productUuid' => new PropertyMetadata(name: 'productUuid', columnName: 'product_uuid', type: 'string', nullable: true), + 'productUuid' => new PropertyMetadata( + name: 'productUuid', + columnName: 'product_uuid', + type: 'string', + nullable: true + ), ], relationships: [ 'lines' => new RelationshipMetadata( @@ -574,9 +651,21 @@ public function create(): QueryBuilderInterface tableName: 'orders', primaryKey: 'uuid', properties: [ - 'uuid' => new PropertyMetadata(name: 'uuid', columnName: 'uuid', type: 'string', nullable: true, isPrimaryKey: true, isAutoIncrement: false), + 'uuid' => new PropertyMetadata( + name: 'uuid', + columnName: 'uuid', + type: 'string', + nullable: true, + isPrimaryKey: true, + isAutoIncrement: false + ), 'status' => new PropertyMetadata(name: 'status', columnName: 'status', type: 'string'), - 'productUuid' => new PropertyMetadata(name: 'productUuid', columnName: 'product_uuid', type: 'string', nullable: true), + 'productUuid' => new PropertyMetadata( + name: 'productUuid', + columnName: 'product_uuid', + type: 'string', + nullable: true + ), ], relationships: [ 'lines' => new RelationshipMetadata( diff --git a/packages/database/tests/Schema/SchemaRegistryTest.php b/packages/database/tests/Schema/SchemaRegistryTest.php index fe2d4d7f..f321f9bc 100644 --- a/packages/database/tests/Schema/SchemaRegistryTest.php +++ b/packages/database/tests/Schema/SchemaRegistryTest.php @@ -295,33 +295,42 @@ ]))->toThrow(EntityException::class); }); -it('updates the EntityMetadataFactory cache so that a subsequent parse(parentClass) returns metadata with extenders populated', function (): void { - $this->registry->registerEntities([ProductEntity::class, ProductExtenderEntity::class]); - - $cachedMetadata = $this->metadataFactory->parse(ProductEntity::class); - - expect($cachedMetadata->extenders)->toContain(ProductExtenderEntity::class); -}); - -it('handles registration order independence when an extender appears before its parent in the input array', function (): void { - // Extender listed first, parent second — must still merge correctly +it( + 'updates the EntityMetadataFactory cache so that a subsequent parse(parentClass) returns metadata with extenders populated', + function (): void { + $this->registry->registerEntities([ProductEntity::class, ProductExtenderEntity::class]); + + $cachedMetadata = $this->metadataFactory->parse(ProductEntity::class); + + expect($cachedMetadata->extenders)->toContain(ProductExtenderEntity::class); + } +); + +it( + 'handles registration order independence when an extender appears before its parent in the input array', + function (): void { + // Extender listed first, parent second — must still merge correctly $this->registry->registerEntities([ProductSecondExtenderEntity::class, ProductEntity::class]); - - $table = $this->registry->getTable('products'); - $columnNames = array_map(fn ($c) => $c->name, $table->columns); - - expect($table)->not->toBeNull() - ->and($columnNames)->toContain('barcode'); -}); - -it('includes a discovered extender from EntityDiscovery in the merged table (regression test for discovery integration)', function (): void { - // Both ProductEntity and ProductExtenderEntity extend Entity with #[Table], + + $table = $this->registry->getTable('products'); + $columnNames = array_map(fn ($c) => $c->name, $table->columns); + + expect($table)->not->toBeNull() + ->and($columnNames)->toContain('barcode'); + } +); + +it( + 'includes a discovered extender from EntityDiscovery in the merged table (regression test for discovery integration)', + function (): void { + // Both ProductEntity and ProductExtenderEntity extend Entity with #[Table], // so EntityDiscovery would find both. Simulate by passing both to registerEntities. $this->registry->registerEntities([ProductEntity::class, ProductExtenderEntity::class]); - - $table = $this->registry->getTable('products'); - - expect($table)->not->toBeNull() - ->and($table->columns)->toHaveCount(3) - ->and($this->registry->getEntityClass('products'))->toBe(ProductEntity::class); -}); + + $table = $this->registry->getTable('products'); + + expect($table)->not->toBeNull() + ->and($table->columns)->toHaveCount(3) + ->and($this->registry->getEntityClass('products'))->toBe(ProductEntity::class); + } +); diff --git a/packages/dev-server/src/Exceptions/DevServerException.php b/packages/dev-server/src/Exceptions/DevServerException.php index 86677b96..4009cf98 100644 --- a/packages/dev-server/src/Exceptions/DevServerException.php +++ b/packages/dev-server/src/Exceptions/DevServerException.php @@ -8,7 +8,10 @@ class DevServerException extends MarkoException { - public static function processFailedToStart(string $name, string $command): self + public static function processFailedToStart( + string $name, + string $command, + ): self { return new self( message: "Failed to start process '$name' with command: $command", diff --git a/packages/encryption/tests/KnownDriversValidationTest.php b/packages/encryption/tests/KnownDriversValidationTest.php index 012b01cc..290c0eb7 100644 --- a/packages/encryption/tests/KnownDriversValidationTest.php +++ b/packages/encryption/tests/KnownDriversValidationTest.php @@ -7,9 +7,12 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all encryption drivers', function () use ($knownDriversPath, $skeletonComposerPath) { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); -}); +test( + 'skeleton suggest block contains all encryption drivers', + function () use ($knownDriversPath, $skeletonComposerPath) { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); + } +); test('every encryption driver follows marko slash prefix pattern', function () use ($knownDriversPath) { KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); diff --git a/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php b/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php index 3cfd4fac..68758869 100644 --- a/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php +++ b/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php @@ -169,16 +169,19 @@ function createFormatterWithReport( ->and($output)->toContain(' and read the docs.'); }); - it('does not linkify text that looks URL-ish but lacks a protocol (e.g., www.example.com without http)', function (): void { - [$formatter, $report] = createFormatterWithReport( - message: 'Visit www.example.com for help', - ); - - $output = $formatter->format($report); - - expect($output)->not->toContain('and($output)->toContain('www.example.com'); + } + ); it('trims trailing punctuation from URL matches (period, comma, etc.)', function (): void { [$formatter, $report] = createFormatterWithReport( diff --git a/packages/errors/tests/KnownDriversValidationTest.php b/packages/errors/tests/KnownDriversValidationTest.php index f4582321..fe937d65 100644 --- a/packages/errors/tests/KnownDriversValidationTest.php +++ b/packages/errors/tests/KnownDriversValidationTest.php @@ -23,9 +23,12 @@ expect(array_key_first($drivers))->toBe('marko/errors-simple'); }); -test('skeleton suggest block contains all errors drivers', function () use ($knownDriversPath, $skeletonComposerPath): void { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); -}); +test( + 'skeleton suggest block contains all errors drivers', + function () use ($knownDriversPath, $skeletonComposerPath): void { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); + } +); test('every errors driver follows marko slash prefix pattern', function () use ($knownDriversPath): void { KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); diff --git a/packages/filesystem/tests/KnownDriversValidationTest.php b/packages/filesystem/tests/KnownDriversValidationTest.php index efd5961e..92976c42 100644 --- a/packages/filesystem/tests/KnownDriversValidationTest.php +++ b/packages/filesystem/tests/KnownDriversValidationTest.php @@ -7,9 +7,12 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all filesystem drivers', function () use ($knownDriversPath, $skeletonComposerPath) { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); -}); +test( + 'skeleton suggest block contains all filesystem drivers', + function () use ($knownDriversPath, $skeletonComposerPath) { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); + } +); test('every filesystem driver follows marko slash prefix pattern', function () use ($knownDriversPath) { KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); diff --git a/packages/framework/tests/ArchitectureDocTest.php b/packages/framework/tests/ArchitectureDocTest.php index e852e813..c2f4310d 100644 --- a/packages/framework/tests/ArchitectureDocTest.php +++ b/packages/framework/tests/ArchitectureDocTest.php @@ -40,14 +40,20 @@ ->and($content)->toContain('UI'); }); -it('the section explains when NOT to use the pattern (application-specific modules)', function () use ($architecturePath) { - $content = file_get_contents($architecturePath); - - expect($content)->toContain('application-specific'); -}); - -it('the section mentions the CrossEngineTemplateParityTest as the enforcement mechanism', function () use ($architecturePath) { - $content = file_get_contents($architecturePath); - - expect($content)->toContain('CrossEngineTemplateParityTest'); -}); +it( + 'the section explains when NOT to use the pattern (application-specific modules)', + function () use ($architecturePath) { + $content = file_get_contents($architecturePath); + + expect($content)->toContain('application-specific'); + } +); + +it( + 'the section mentions the CrossEngineTemplateParityTest as the enforcement mechanism', + function () use ($architecturePath) { + $content = file_get_contents($architecturePath); + + expect($content)->toContain('CrossEngineTemplateParityTest'); + } +); diff --git a/packages/framework/tests/PackagistPublishingTest.php b/packages/framework/tests/PackagistPublishingTest.php index 67cb3479..af2dd7b9 100644 --- a/packages/framework/tests/PackagistPublishingTest.php +++ b/packages/framework/tests/PackagistPublishingTest.php @@ -21,7 +21,12 @@ function getPackageComposerFiles(): array } } - expect($withRepos)->toBeEmpty('These package composer.json files still contain a "repositories" key: ' . implode(', ', array_map('basename', array_map('dirname', $withRepos)))); + expect($withRepos)->toBeEmpty( + 'These package composer.json files still contain a "repositories" key: ' . implode( + ', ', + array_map('basename', array_map('dirname', $withRepos)) + ) + ); }); it('changes all internal marko/* require constraints from @dev to self.version', function (): void { @@ -69,7 +74,9 @@ function getPackageComposerFiles(): array } } - expect($violations)->toBeEmpty('These require-dev constraints are not self.version: ' . implode(', ', $violations)); + expect($violations)->toBeEmpty( + 'These require-dev constraints are not self.version: ' . implode(', ', $violations) + ); }); it('changes marko/dev-server wildcard constraints to self.version', function (): void { @@ -83,7 +90,9 @@ function getPackageComposerFiles(): array } } - expect($violations)->toBeEmpty('dev-server has non-self.version marko/* constraints: ' . implode(', ', $violations)); + expect($violations)->toBeEmpty( + 'dev-server has non-self.version marko/* constraints: ' . implode(', ', $violations) + ); }); it('changes any remaining wildcard marko/* constraints to self.version', function (): void { @@ -126,7 +135,9 @@ function getPackageComposerFiles(): array } } - expect($violations)->toBeEmpty('These non-marko dependencies incorrectly use self.version: ' . implode(', ', $violations)); + expect($violations)->toBeEmpty( + 'These non-marko dependencies incorrectly use self.version: ' . implode(', ', $violations) + ); }); it('preserves all other composer.json keys (autoload, extra, config, suggest, etc.) unchanged', function (): void { @@ -160,5 +171,7 @@ function getPackageComposerFiles(): array } } - expect($violations)->toBeEmpty('Structural violations in package composer.json files: ' . implode(', ', $violations)); + expect($violations)->toBeEmpty( + 'Structural violations in package composer.json files: ' . implode(', ', $violations) + ); }); diff --git a/packages/framework/tests/RootComposerJsonTest.php b/packages/framework/tests/RootComposerJsonTest.php index 854981e5..e338d276 100644 --- a/packages/framework/tests/RootComposerJsonTest.php +++ b/packages/framework/tests/RootComposerJsonTest.php @@ -81,38 +81,48 @@ 'marko/webhook', ]; -it('adds a require section entry for all 73 marko packages set to self.version', function () use ($rootComposer, $allPackages): void { - expect($rootComposer)->toHaveKey('require'); - - foreach ($allPackages as $package) { - expect($rootComposer['require'])->toHaveKey($package) - ->and($rootComposer['require'][$package])->toBe('self.version'); +it( + 'adds a require section entry for all 73 marko packages set to self.version', + function () use ($rootComposer, $allPackages): void { + expect($rootComposer)->toHaveKey('require'); + + foreach ($allPackages as $package) { + expect($rootComposer['require'])->toHaveKey($package) + ->and($rootComposer['require'][$package])->toBe('self.version'); + } } -}); - -it('does not have a replace section (path repos install as symlinks without it)', function () use ($rootComposer): void { - expect($rootComposer)->not->toHaveKey('replace'); -}); +); -it('adds repositories section with path repos for all 73 packages', function () use ($rootComposer, $allPackages): void { - expect($rootComposer)->toHaveKey('repositories'); - - $repoUrls = array_column($rootComposer['repositories'], 'url'); - - foreach ($allPackages as $package) { - $packageName = str_replace('marko/', '', $package); - expect(in_array("packages/$packageName", $repoUrls, true))->toBeTrue(); +it( + 'does not have a replace section (path repos install as symlinks without it)', + function () use ($rootComposer): void { + expect($rootComposer)->not->toHaveKey('replace'); } - - foreach ($rootComposer['repositories'] as $repo) { - expect($repo)->toHaveKey('type') - ->and($repo['type'])->toBe('path'); +); + +it( + 'adds repositories section with path repos for all 73 packages', + function () use ($rootComposer, $allPackages): void { + expect($rootComposer)->toHaveKey('repositories'); + + $repoUrls = array_column($rootComposer['repositories'], 'url'); + + foreach ($allPackages as $package) { + $packageName = str_replace('marko/', '', $package); + expect(in_array("packages/$packageName", $repoUrls, true))->toBeTrue(); + } + + foreach ($rootComposer['repositories'] as $repo) { + expect($repo)->toHaveKey('type') + ->and($repo['type'])->toBe('path'); + } } -}); +); it('removes all manual PSR-4 autoload entries for marko packages', function () use ($rootComposer): void { if (!isset($rootComposer['autoload']['psr-4'])) { expect(true)->toBeTrue(); + return; } @@ -121,20 +131,23 @@ } }); -it('keeps autoload-dev entries for test namespaces (Composer does not merge autoload-dev from dependencies)', function () use ($rootComposer, $allPackages): void { - // autoload-dev must remain in root: Composer only applies a package's autoload-dev +it( + 'keeps autoload-dev entries for test namespaces (Composer does not merge autoload-dev from dependencies)', + function () use ($rootComposer, $allPackages): void { + // autoload-dev must remain in root: Composer only applies a package's autoload-dev // when it is the root package, so test namespaces for all monorepo packages must // be declared here to be discoverable when running the test suite. expect($rootComposer)->toHaveKey('autoload-dev') - ->and($rootComposer['autoload-dev'])->toHaveKey('psr-4'); - - $devPsr4 = $rootComposer['autoload-dev']['psr-4']; - $hasAtLeastOneTestNamespace = array_any( - array_keys($devPsr4), - fn (string $ns): bool => str_ends_with($ns, 'Tests\\'), - ); - expect($hasAtLeastOneTestNamespace)->toBeTrue(); -}); + ->and($rootComposer['autoload-dev'])->toHaveKey('psr-4'); + + $devPsr4 = $rootComposer['autoload-dev']['psr-4']; + $hasAtLeastOneTestNamespace = array_any( + array_keys($devPsr4), + fn (string $ns): bool => str_ends_with($ns, 'Tests\\'), + ); + expect($hasAtLeastOneTestNamespace)->toBeTrue(); + } +); it('removes the autoload files entry for packages/env/src/functions.php', function () use ($rootComposer): void { $files = $rootComposer['autoload']['files'] ?? []; @@ -142,32 +155,35 @@ expect(in_array('packages/env/src/functions.php', $files, true))->toBeFalse(); }); -it('preserves existing require (php, ext-*) and require-dev (third-party) entries', function () use ($rootComposer): void { - expect($rootComposer['require'])->toHaveKey('php') - ->and($rootComposer['require']['php'])->toBe('^8.5') - ->and($rootComposer['require'])->toHaveKey('ext-pdo') - ->and($rootComposer['require'])->toHaveKey('ext-fileinfo') - ->and($rootComposer['require'])->toHaveKey('ext-gd') - ->and($rootComposer['require'])->toHaveKey('ext-imagick'); - - $expectedDevPackages = [ - 'amphp/postgres', - 'amphp/redis', - 'aws/aws-sdk-php', - 'friendsofphp/php-cs-fixer', - 'guzzlehttp/guzzle', - 'pestphp/pest', - 'php-amqplib/php-amqplib', - 'predis/predis', - 'rector/rector', - 'slevomat/coding-standard', - 'squizlabs/php_codesniffer', - ]; - - foreach ($expectedDevPackages as $package) { - expect($rootComposer['require-dev'])->toHaveKey($package); +it( + 'preserves existing require (php, ext-*) and require-dev (third-party) entries', + function () use ($rootComposer): void { + expect($rootComposer['require'])->toHaveKey('php') + ->and($rootComposer['require']['php'])->toBe('^8.5') + ->and($rootComposer['require'])->toHaveKey('ext-pdo') + ->and($rootComposer['require'])->toHaveKey('ext-fileinfo') + ->and($rootComposer['require'])->toHaveKey('ext-gd') + ->and($rootComposer['require'])->toHaveKey('ext-imagick'); + + $expectedDevPackages = [ + 'amphp/postgres', + 'amphp/redis', + 'aws/aws-sdk-php', + 'friendsofphp/php-cs-fixer', + 'guzzlehttp/guzzle', + 'pestphp/pest', + 'php-amqplib/php-amqplib', + 'predis/predis', + 'rector/rector', + 'slevomat/coding-standard', + 'squizlabs/php_codesniffer', + ]; + + foreach ($expectedDevPackages as $package) { + expect($rootComposer['require-dev'])->toHaveKey($package); + } } -}); +); it('preserves scripts, config, and other root-level settings', function () use ($rootComposer): void { expect($rootComposer)->toHaveKey('scripts') @@ -181,10 +197,13 @@ ->and($rootComposer['config'])->toHaveKey('allow-plugins'); }); -it('keeps minimum-stability as stable (replace bypasses stability checks for replaced packages)', function () use ($rootComposer): void { - expect($rootComposer)->toHaveKey('minimum-stability') - ->and($rootComposer['minimum-stability'])->toBe('stable'); -}); +it( + 'keeps minimum-stability as stable (replace bypasses stability checks for replaced packages)', + function () use ($rootComposer): void { + expect($rootComposer)->toHaveKey('minimum-stability') + ->and($rootComposer['minimum-stability'])->toBe('stable'); + } +); it('keeps prefer-stable as true', function () use ($rootComposer): void { expect($rootComposer)->toHaveKey('prefer-stable') diff --git a/packages/health/tests/Unit/FilesystemHealthCheckTest.php b/packages/health/tests/Unit/FilesystemHealthCheckTest.php index 1a009266..67244f53 100644 --- a/packages/health/tests/Unit/FilesystemHealthCheckTest.php +++ b/packages/health/tests/Unit/FilesystemHealthCheckTest.php @@ -44,19 +44,30 @@ public function readStream(string $path): mixed throw new RuntimeException('Not implemented'); } - public function write(string $path, string $contents, array $options = []): bool + public function write( + string $path, + string $contents, + array $options = [], + ): bool { $this->written[$path] = $contents; return true; } - public function writeStream(string $path, mixed $resource, array $options = []): bool + public function writeStream( + string $path, + mixed $resource, + array $options = [], + ): bool { return true; } - public function append(string $path, string $contents): bool + public function append( + string $path, + string $contents, + ): bool { return true; } @@ -68,12 +79,18 @@ public function delete(string $path): bool return true; } - public function copy(string $source, string $destination): bool + public function copy( + string $source, + string $destination, + ): bool { return true; } - public function move(string $source, string $destination): bool + public function move( + string $source, + string $destination, + ): bool { return true; } @@ -108,7 +125,10 @@ public function deleteDirectory(string $path): bool return true; } - public function setVisibility(string $path, string $visibility): bool + public function setVisibility( + string $path, + string $visibility, + ): bool { return true; } @@ -162,17 +182,28 @@ public function readStream(string $path): mixed throw new RuntimeException('Not implemented'); } - public function write(string $path, string $contents, array $options = []): bool + public function write( + string $path, + string $contents, + array $options = [], + ): bool { throw new RuntimeException('Filesystem not writable'); } - public function writeStream(string $path, mixed $resource, array $options = []): bool + public function writeStream( + string $path, + mixed $resource, + array $options = [], + ): bool { return false; } - public function append(string $path, string $contents): bool + public function append( + string $path, + string $contents, + ): bool { return false; } @@ -182,12 +213,18 @@ public function delete(string $path): bool return false; } - public function copy(string $source, string $destination): bool + public function copy( + string $source, + string $destination, + ): bool { return false; } - public function move(string $source, string $destination): bool + public function move( + string $source, + string $destination, + ): bool { return false; } @@ -222,7 +259,10 @@ public function deleteDirectory(string $path): bool return false; } - public function setVisibility(string $path, string $visibility): bool + public function setVisibility( + string $path, + string $visibility, + ): bool { return false; } diff --git a/packages/http/tests/KnownDriversValidationTest.php b/packages/http/tests/KnownDriversValidationTest.php index f71e5785..c111eaee 100644 --- a/packages/http/tests/KnownDriversValidationTest.php +++ b/packages/http/tests/KnownDriversValidationTest.php @@ -7,5 +7,11 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all http drivers', fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)); -test('every http driver follows marko slash prefix pattern', fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)); +test( + 'skeleton suggest block contains all http drivers', + fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath) +); +test( + 'every http driver follows marko slash prefix pattern', + fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath) +); diff --git a/packages/inertia/tests/KnownDriversValidationTest.php b/packages/inertia/tests/KnownDriversValidationTest.php index 275da234..d2d84e9c 100644 --- a/packages/inertia/tests/KnownDriversValidationTest.php +++ b/packages/inertia/tests/KnownDriversValidationTest.php @@ -7,9 +7,12 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all inertia drivers', function () use ($knownDriversPath, $skeletonComposerPath) { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); -}); +test( + 'skeleton suggest block contains all inertia drivers', + function () use ($knownDriversPath, $skeletonComposerPath) { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); + } +); test('every inertia driver follows marko slash prefix pattern', function () use ($knownDriversPath) { KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); diff --git a/packages/layout/src/ComponentCollection.php b/packages/layout/src/ComponentCollection.php index 6e9779e8..1da69330 100644 --- a/packages/layout/src/ComponentCollection.php +++ b/packages/layout/src/ComponentCollection.php @@ -76,7 +76,11 @@ public function forSlot(string $slot): array /** * @throws ComponentNotFoundException */ - public function move(string $className, string $newSlot, ?int $newSortOrder = null): void + public function move( + string $className, + string $newSlot, + ?int $newSortOrder = null, + ): void { $definition = $this->get($className); @@ -124,7 +128,10 @@ public function groupedBySlot(): array * @return array * @throws AmbiguousSortOrderException */ - private function sort(array $components, string $slot): array + private function sort( + array $components, + string $slot, + ): array { // First pass: sort by sortOrder, detecting ambiguities usort($components, function (ComponentDefinition $a, ComponentDefinition $b) use ($slot): int { diff --git a/packages/layout/src/ComponentCollector.php b/packages/layout/src/ComponentCollector.php index 0a1b8b26..96bec78e 100644 --- a/packages/layout/src/ComponentCollector.php +++ b/packages/layout/src/ComponentCollector.php @@ -24,7 +24,10 @@ public function __construct( * @param array $classNames * @throws Error|ReflectionException|Exceptions\DuplicateComponentException */ - public function collect(array $classNames, string $handle): ComponentCollection + public function collect( + array $classNames, + string $handle, + ): ComponentCollection { $collection = new ComponentCollection(); @@ -136,7 +139,10 @@ private function resolveHandles(string|array $handle): array /** * Resolve a class-reference handle to a string handle via RouteCollection. */ - private function resolveClassReferenceHandle(string $controllerClass, string $action): ?string + private function resolveClassReferenceHandle( + string $controllerClass, + string $action, + ): ?string { foreach ($this->routeCollection->all() as $route) { if ($route->controller === $controllerClass && $route->action === $action) { diff --git a/packages/layout/src/ComponentCollectorInterface.php b/packages/layout/src/ComponentCollectorInterface.php index b2a85b0c..843bf6ae 100644 --- a/packages/layout/src/ComponentCollectorInterface.php +++ b/packages/layout/src/ComponentCollectorInterface.php @@ -11,7 +11,10 @@ interface ComponentCollectorInterface * * @param array $classNames */ - public function collect(array $classNames, string $handle): ComponentCollection; + public function collect( + array $classNames, + string $handle, + ): ComponentCollection; /** * Discover a ComponentDefinition from a single class. diff --git a/packages/layout/src/ComponentDataResolver.php b/packages/layout/src/ComponentDataResolver.php index b4b67b68..906f9788 100644 --- a/packages/layout/src/ComponentDataResolver.php +++ b/packages/layout/src/ComponentDataResolver.php @@ -17,7 +17,11 @@ * @return array * @throws ReflectionException */ - public function resolve(object $component, array $routeParams, Request $request): array + public function resolve( + object $component, + array $routeParams, + Request $request, + ): array { if (!method_exists($component, 'data')) { return []; @@ -47,7 +51,10 @@ public function resolve(object $component, array $routeParams, Request $request) return $component->data(...$parameters); } - private function castToType(mixed $value, ?ReflectionType $type): mixed + private function castToType( + mixed $value, + ?ReflectionType $type, + ): mixed { if (!$type instanceof ReflectionNamedType) { return $value; diff --git a/packages/layout/src/DiscoveringComponentCollector.php b/packages/layout/src/DiscoveringComponentCollector.php index 2447facd..ee9ecc86 100644 --- a/packages/layout/src/DiscoveringComponentCollector.php +++ b/packages/layout/src/DiscoveringComponentCollector.php @@ -27,7 +27,10 @@ public function __construct( * @param array $classNames * @throws DuplicateComponentException|Error|ReflectionException */ - public function collect(array $classNames, string $handle): ComponentCollection + public function collect( + array $classNames, + string $handle, + ): ComponentCollection { $discovered = $this->discoverComponentClasses(); $merged = array_values(array_unique(array_merge($discovered, $classNames))); diff --git a/packages/layout/src/Exceptions/AmbiguousSortOrderException.php b/packages/layout/src/Exceptions/AmbiguousSortOrderException.php index cca58f22..8513d20d 100644 --- a/packages/layout/src/Exceptions/AmbiguousSortOrderException.php +++ b/packages/layout/src/Exceptions/AmbiguousSortOrderException.php @@ -9,7 +9,11 @@ class AmbiguousSortOrderException extends LayoutException /** * @param array $components */ - public static function forComponents(string $slot, int $sortOrder, array $components): self + public static function forComponents( + string $slot, + int $sortOrder, + array $components, + ): self { $componentList = implode(', ', $components); diff --git a/packages/layout/src/Exceptions/CircularSlotException.php b/packages/layout/src/Exceptions/CircularSlotException.php index 7fea8429..127d9617 100644 --- a/packages/layout/src/Exceptions/CircularSlotException.php +++ b/packages/layout/src/Exceptions/CircularSlotException.php @@ -9,7 +9,10 @@ class CircularSlotException extends LayoutException /** * @param array $chain */ - public static function forSlot(string $slot, array $chain): self + public static function forSlot( + string $slot, + array $chain, + ): self { $chainPath = implode(' -> ', $chain); diff --git a/packages/layout/src/Exceptions/DuplicateComponentException.php b/packages/layout/src/Exceptions/DuplicateComponentException.php index a816ccbc..4d55b932 100644 --- a/packages/layout/src/Exceptions/DuplicateComponentException.php +++ b/packages/layout/src/Exceptions/DuplicateComponentException.php @@ -6,7 +6,11 @@ class DuplicateComponentException extends LayoutException { - public static function forComponent(string $name, string $moduleA, string $moduleB): self + public static function forComponent( + string $name, + string $moduleA, + string $moduleB, + ): self { return new self( message: "Component '$name' is registered in multiple modules.", diff --git a/packages/layout/src/Exceptions/SlotNotFoundException.php b/packages/layout/src/Exceptions/SlotNotFoundException.php index 3e55aabf..834385a2 100644 --- a/packages/layout/src/Exceptions/SlotNotFoundException.php +++ b/packages/layout/src/Exceptions/SlotNotFoundException.php @@ -6,7 +6,10 @@ class SlotNotFoundException extends LayoutException { - public static function forSlot(string $slot, string $layout): self + public static function forSlot( + string $slot, + string $layout, + ): self { return new self( message: "Slot '$slot' not found in layout '$layout'.", diff --git a/packages/layout/src/HandleResolver.php b/packages/layout/src/HandleResolver.php index 0069191b..253b33e8 100644 --- a/packages/layout/src/HandleResolver.php +++ b/packages/layout/src/HandleResolver.php @@ -6,7 +6,11 @@ readonly class HandleResolver { - public function generate(string $path, string $controllerClass, string $action): string + public function generate( + string $path, + string $controllerClass, + string $action, + ): string { $segments = explode('/', trim($path, '/')); $routePart = $segments[0] !== '' ? $segments[0] : 'index'; @@ -17,7 +21,10 @@ public function generate(string $path, string $controllerClass, string $action): return strtolower("{$routePart}_{$controllerPart}_$action"); } - public function matches(string $componentHandle, string $pageHandle): bool + public function matches( + string $componentHandle, + string $pageHandle, + ): bool { if ($componentHandle === 'default') { return true; diff --git a/packages/layout/src/LayoutProcessor.php b/packages/layout/src/LayoutProcessor.php index 825bc634..97c9b71a 100644 --- a/packages/layout/src/LayoutProcessor.php +++ b/packages/layout/src/LayoutProcessor.php @@ -124,7 +124,10 @@ private function renderSlot( * @param array $topLevelSlots * @throws CircularSlotException */ - private function detectCircularReferences(ComponentCollection $collection, array $topLevelSlots): void + private function detectCircularReferences( + ComponentCollection $collection, + array $topLevelSlots, + ): void { // Build a map: slot name -> sub-slots it leads to (via components in that slot) /** @var array> $slotToSubSlots */ diff --git a/packages/layout/src/LayoutResolver.php b/packages/layout/src/LayoutResolver.php index e309e1b4..e03511f4 100644 --- a/packages/layout/src/LayoutResolver.php +++ b/packages/layout/src/LayoutResolver.php @@ -23,7 +23,10 @@ * @return array{componentClass: class-string, attribute: Component} * @throws LayoutNotFoundException|ReflectionException */ - public function resolve(string $controllerClass, string $method): array + public function resolve( + string $controllerClass, + string $method, + ): array { $classReflection = new ReflectionClass($controllerClass); $methodReflection = new ReflectionMethod($controllerClass, $method); diff --git a/packages/layout/tests/Exceptions/AmbiguousSortOrderExceptionTest.php b/packages/layout/tests/Exceptions/AmbiguousSortOrderExceptionTest.php index 43d8f465..f10e3708 100644 --- a/packages/layout/tests/Exceptions/AmbiguousSortOrderExceptionTest.php +++ b/packages/layout/tests/Exceptions/AmbiguousSortOrderExceptionTest.php @@ -5,11 +5,14 @@ use Marko\Layout\Exceptions\AmbiguousSortOrderException; use Marko\Layout\Exceptions\LayoutException; -it('has an AmbiguousSortOrderException with a static factory method providing message, context, and suggestion', function (): void { - $exception = AmbiguousSortOrderException::forComponents('header', 10, ['ComponentA', 'ComponentB']); - - expect($exception)->toBeInstanceOf(LayoutException::class) - ->and($exception->getMessage())->toContain('header') - ->and($exception->getContext())->not->toBeEmpty() - ->and($exception->getSuggestion())->not->toBeEmpty(); -}); +it( + 'has an AmbiguousSortOrderException with a static factory method providing message, context, and suggestion', + function (): void { + $exception = AmbiguousSortOrderException::forComponents('header', 10, ['ComponentA', 'ComponentB']); + + expect($exception)->toBeInstanceOf(LayoutException::class) + ->and($exception->getMessage())->toContain('header') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->not->toBeEmpty(); + } +); diff --git a/packages/layout/tests/Exceptions/CircularSlotExceptionTest.php b/packages/layout/tests/Exceptions/CircularSlotExceptionTest.php index 3cc1b5ce..deb97de0 100644 --- a/packages/layout/tests/Exceptions/CircularSlotExceptionTest.php +++ b/packages/layout/tests/Exceptions/CircularSlotExceptionTest.php @@ -5,11 +5,14 @@ use Marko\Layout\Exceptions\CircularSlotException; use Marko\Layout\Exceptions\LayoutException; -it('has a CircularSlotException with a static factory method providing message, context, and suggestion', function (): void { - $exception = CircularSlotException::forSlot('main', ['main', 'nested', 'main']); - - expect($exception)->toBeInstanceOf(LayoutException::class) - ->and($exception->getMessage())->toContain('main') - ->and($exception->getContext())->not->toBeEmpty() - ->and($exception->getSuggestion())->not->toBeEmpty(); -}); +it( + 'has a CircularSlotException with a static factory method providing message, context, and suggestion', + function (): void { + $exception = CircularSlotException::forSlot('main', ['main', 'nested', 'main']); + + expect($exception)->toBeInstanceOf(LayoutException::class) + ->and($exception->getMessage())->toContain('main') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->not->toBeEmpty(); + } +); diff --git a/packages/layout/tests/Exceptions/ComponentNotFoundExceptionTest.php b/packages/layout/tests/Exceptions/ComponentNotFoundExceptionTest.php index 15a20745..51b37d54 100644 --- a/packages/layout/tests/Exceptions/ComponentNotFoundExceptionTest.php +++ b/packages/layout/tests/Exceptions/ComponentNotFoundExceptionTest.php @@ -5,11 +5,14 @@ use Marko\Layout\Exceptions\ComponentNotFoundException; use Marko\Layout\Exceptions\LayoutException; -it('has a ComponentNotFoundException with a static factory method providing message, context, and suggestion', function (): void { - $exception = ComponentNotFoundException::forComponent('alert'); - - expect($exception)->toBeInstanceOf(LayoutException::class) - ->and($exception->getMessage())->toContain('alert') - ->and($exception->getContext())->not->toBeEmpty() - ->and($exception->getSuggestion())->not->toBeEmpty(); -}); +it( + 'has a ComponentNotFoundException with a static factory method providing message, context, and suggestion', + function (): void { + $exception = ComponentNotFoundException::forComponent('alert'); + + expect($exception)->toBeInstanceOf(LayoutException::class) + ->and($exception->getMessage())->toContain('alert') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->not->toBeEmpty(); + } +); diff --git a/packages/layout/tests/Exceptions/DuplicateComponentExceptionTest.php b/packages/layout/tests/Exceptions/DuplicateComponentExceptionTest.php index fc182d41..84d7e8c6 100644 --- a/packages/layout/tests/Exceptions/DuplicateComponentExceptionTest.php +++ b/packages/layout/tests/Exceptions/DuplicateComponentExceptionTest.php @@ -5,11 +5,14 @@ use Marko\Layout\Exceptions\DuplicateComponentException; use Marko\Layout\Exceptions\LayoutException; -it('has a DuplicateComponentException with a static factory method providing message, context, and suggestion', function (): void { - $exception = DuplicateComponentException::forComponent('header', 'ModuleA', 'ModuleB'); - - expect($exception)->toBeInstanceOf(LayoutException::class) - ->and($exception->getMessage())->toContain('header') - ->and($exception->getContext())->not->toBeEmpty() - ->and($exception->getSuggestion())->not->toBeEmpty(); -}); +it( + 'has a DuplicateComponentException with a static factory method providing message, context, and suggestion', + function (): void { + $exception = DuplicateComponentException::forComponent('header', 'ModuleA', 'ModuleB'); + + expect($exception)->toBeInstanceOf(LayoutException::class) + ->and($exception->getMessage())->toContain('header') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->not->toBeEmpty(); + } +); diff --git a/packages/layout/tests/Exceptions/LayoutNotFoundExceptionTest.php b/packages/layout/tests/Exceptions/LayoutNotFoundExceptionTest.php index 334e31c0..549d17fb 100644 --- a/packages/layout/tests/Exceptions/LayoutNotFoundExceptionTest.php +++ b/packages/layout/tests/Exceptions/LayoutNotFoundExceptionTest.php @@ -5,11 +5,14 @@ use Marko\Layout\Exceptions\LayoutException; use Marko\Layout\Exceptions\LayoutNotFoundException; -it('has a LayoutNotFoundException with a static factory method providing message, context, and suggestion', function (): void { - $exception = LayoutNotFoundException::forLayout('admin'); - - expect($exception)->toBeInstanceOf(LayoutException::class) - ->and($exception->getMessage())->toContain('admin') - ->and($exception->getContext())->not->toBeEmpty() - ->and($exception->getSuggestion())->not->toBeEmpty(); -}); +it( + 'has a LayoutNotFoundException with a static factory method providing message, context, and suggestion', + function (): void { + $exception = LayoutNotFoundException::forLayout('admin'); + + expect($exception)->toBeInstanceOf(LayoutException::class) + ->and($exception->getMessage())->toContain('admin') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->not->toBeEmpty(); + } +); diff --git a/packages/layout/tests/Exceptions/SlotNotFoundExceptionTest.php b/packages/layout/tests/Exceptions/SlotNotFoundExceptionTest.php index 9e31c15d..0ea55b27 100644 --- a/packages/layout/tests/Exceptions/SlotNotFoundExceptionTest.php +++ b/packages/layout/tests/Exceptions/SlotNotFoundExceptionTest.php @@ -5,11 +5,14 @@ use Marko\Layout\Exceptions\LayoutException; use Marko\Layout\Exceptions\SlotNotFoundException; -it('has a SlotNotFoundException with a static factory method providing message, context, and suggestion', function (): void { - $exception = SlotNotFoundException::forSlot('sidebar', 'main-layout'); - - expect($exception)->toBeInstanceOf(LayoutException::class) - ->and($exception->getMessage())->toContain('sidebar') - ->and($exception->getContext())->not->toBeEmpty() - ->and($exception->getSuggestion())->not->toBeEmpty(); -}); +it( + 'has a SlotNotFoundException with a static factory method providing message, context, and suggestion', + function (): void { + $exception = SlotNotFoundException::forSlot('sidebar', 'main-layout'); + + expect($exception)->toBeInstanceOf(LayoutException::class) + ->and($exception->getMessage())->toContain('sidebar') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->not->toBeEmpty(); + } +); diff --git a/packages/layout/tests/Unit/ComponentCollectionTest.php b/packages/layout/tests/Unit/ComponentCollectionTest.php index 7bf30e6a..e99d961a 100644 --- a/packages/layout/tests/Unit/ComponentCollectionTest.php +++ b/packages/layout/tests/Unit/ComponentCollectionTest.php @@ -146,16 +146,19 @@ className: $className, ->and($result[1]->className)->toBe('App\Components\BComponent'); }); -it('throws AmbiguousSortOrderException when two components have same sortOrder with no before or after constraints', function (): void { - $collection = new ComponentCollection(); - $a = makeDefinition('App\Components\AComponent', 'header', sortOrder: 10); - $b = makeDefinition('App\Components\BComponent', 'header', sortOrder: 10); - $collection->add($a); - $collection->add($b); - - expect(fn () => $collection->forSlot('header')) - ->toThrow(AmbiguousSortOrderException::class); -}); +it( + 'throws AmbiguousSortOrderException when two components have same sortOrder with no before or after constraints', + function (): void { + $collection = new ComponentCollection(); + $a = makeDefinition('App\Components\AComponent', 'header', sortOrder: 10); + $b = makeDefinition('App\Components\BComponent', 'header', sortOrder: 10); + $collection->add($a); + $collection->add($b); + + expect(fn () => $collection->forSlot('header')) + ->toThrow(AmbiguousSortOrderException::class); + } +); it('moves a component to a different slot', function (): void { $collection = new ComponentCollection(); diff --git a/packages/layout/tests/Unit/ComponentCollectorTest.php b/packages/layout/tests/Unit/ComponentCollectorTest.php index 46363ca2..c0ab25e3 100644 --- a/packages/layout/tests/Unit/ComponentCollectorTest.php +++ b/packages/layout/tests/Unit/ComponentCollectorTest.php @@ -25,7 +25,11 @@ class CatalogPrefixComponent {} #[Component(template: 'other/page.phtml', slot: 'content', handle: 'checkout_cart')] class OtherPageComponent {} -#[Component(template: 'multi/component.phtml', slot: 'content', handle: ['catalog_product_show', 'catalog_category_view'])] +#[Component( + template: 'multi/component.phtml', + slot: 'content', + handle: ['catalog_product_show', 'catalog_category_view'] +)] class MultiHandleComponent {} #[Component(template: 'root/layout.phtml', slots: ['content', 'sidebar'], handle: 'default')] diff --git a/packages/layout/tests/Unit/Helpers.php b/packages/layout/tests/Unit/Helpers.php index 2d755d45..866e140d 100644 --- a/packages/layout/tests/Unit/Helpers.php +++ b/packages/layout/tests/Unit/Helpers.php @@ -25,7 +25,10 @@ public function has(string $id): bool public function singleton(string $id): void {} - public function instance(string $id, object $instance): void {} + public function instance( + string $id, + object $instance, + ): void {} public function call(Closure $callable): mixed { diff --git a/packages/layout/tests/Unit/LayoutProcessorNestedTest.php b/packages/layout/tests/Unit/LayoutProcessorNestedTest.php index 623e3cbb..9301b559 100644 --- a/packages/layout/tests/Unit/LayoutProcessorNestedTest.php +++ b/packages/layout/tests/Unit/LayoutProcessorNestedTest.php @@ -30,7 +30,13 @@ public function index(): void {} class LpnFixtureRootComponent {} // A component that lives in "content" slot but defines sub-slots: tab.details and tab.reviews -#[Component(template: 'components/tabs.html', slot: 'content', handle: 'default', sortOrder: 10, slots: ['tab.details', 'tab.reviews'])] +#[Component( + template: 'components/tabs.html', + slot: 'content', + handle: 'default', + sortOrder: 10, + slots: ['tab.details', 'tab.reviews'] +)] class LpnFixtureTabsComponent {} // Components targeting sub-slots @@ -53,7 +59,13 @@ class LpnFixtureTwoSlotRootComponent {} #[Component(template: 'components/nav.html', slot: 'header', handle: 'default', sortOrder: 10)] class LpnFixtureNavComponent {} -#[Component(template: 'components/body.html', slot: 'main', handle: 'default', sortOrder: 10, slots: ['body.sidebar', 'body.content'])] +#[Component( + template: 'components/body.html', + slot: 'main', + handle: 'default', + sortOrder: 10, + slots: ['body.sidebar', 'body.content'] +)] class LpnFixtureBodyComponent {} #[Component(template: 'components/sidebar.html', slot: 'body.sidebar', handle: 'default', sortOrder: 10)] @@ -63,16 +75,33 @@ class LpnFixtureSidebarNestedComponent {} class LpnFixtureMainContentComponent {} // Deep nesting: tab.details itself defines sub-slots -#[Component(template: 'components/tabs-deep.html', slot: 'content', handle: 'default', sortOrder: 10, slots: ['tab.details'])] +#[Component( + template: 'components/tabs-deep.html', + slot: 'content', + handle: 'default', + sortOrder: 10, + slots: ['tab.details'] +)] class LpnFixtureDeepTabsComponent {} -#[Component(template: 'components/deep-details.html', slot: 'tab.details', handle: 'default', sortOrder: 10, slots: ['detail.images', 'detail.description'])] +#[Component( + template: 'components/deep-details.html', + slot: 'tab.details', + handle: 'default', + sortOrder: 10, + slots: ['detail.images', 'detail.description'] +)] class LpnFixtureDeepDetailsComponent {} #[Component(template: 'components/detail-images.html', slot: 'detail.images', handle: 'default', sortOrder: 10)] class LpnFixtureDetailImagesComponent {} -#[Component(template: 'components/detail-description.html', slot: 'detail.description', handle: 'default', sortOrder: 20)] +#[Component( + template: 'components/detail-description.html', + slot: 'detail.description', + handle: 'default', + sortOrder: 20 +)] class LpnFixtureDetailDescriptionComponent {} // Circular reference fixtures @@ -80,10 +109,22 @@ class LpnFixtureDetailDescriptionComponent {} // B is in cycle.b, B defines sub-slot: content (pointing back to layout's top-level!) // Actually: circular means slot graph has a cycle. Let's use: // X defines sub-slot: cycle.y; Y defines sub-slot: cycle.x; X is in cycle.x; Y is in cycle.y -#[Component(template: 'components/cycle-x.html', slot: 'content', handle: 'default', sortOrder: 10, slots: ['cycle.y'])] +#[Component( + template: 'components/cycle-x.html', + slot: 'content', + handle: 'default', + sortOrder: 10, + slots: ['cycle.y'] +)] class LpnFixtureCycleXComponent {} -#[Component(template: 'components/cycle-y.html', slot: 'cycle.y', handle: 'default', sortOrder: 10, slots: ['cycle.x'])] +#[Component( + template: 'components/cycle-y.html', + slot: 'cycle.y', + handle: 'default', + sortOrder: 10, + slots: ['cycle.x'] +)] class LpnFixtureCycleYComponent {} #[Component(template: 'components/cycle-filler.html', slot: 'cycle.x', handle: 'default', sortOrder: 10)] @@ -97,7 +138,10 @@ function lpnStubCollector(ComponentCollection $collection): ComponentCollectorIn { public function __construct(private readonly ComponentCollection $stub) {} - public function collect(array $classNames, string $handle): ComponentCollection + public function collect( + array $classNames, + string $handle, + ): ComponentCollection { return $this->stub; } @@ -115,12 +159,18 @@ function lpnStubView(callable $renderFn): ViewInterface { public function __construct(private readonly mixed $renderFn) {} - public function render(string $template, array $data = []): Response + public function render( + string $template, + array $data = [], + ): Response { return Response::html(($this->renderFn)($template, $data)); } - public function renderToString(string $template, array $data = []): string + public function renderToString( + string $template, + array $data = [], + ): string { return ($this->renderFn)($template, $data); } diff --git a/packages/layout/tests/Unit/LayoutProcessorTest.php b/packages/layout/tests/Unit/LayoutProcessorTest.php index 38318ab8..c9b65467 100644 --- a/packages/layout/tests/Unit/LayoutProcessorTest.php +++ b/packages/layout/tests/Unit/LayoutProcessorTest.php @@ -93,7 +93,10 @@ function stubCollector(ComponentCollection $collection): ComponentCollectorInter { public function __construct(private readonly ComponentCollection $stub) {} - public function collect(array $classNames, string $handle): ComponentCollection + public function collect( + array $classNames, + string $handle, + ): ComponentCollection { return $this->stub; } @@ -112,12 +115,18 @@ function stubView(callable $renderFn): ViewInterface { public function __construct(private readonly mixed $renderFn) {} - public function render(string $template, array $data = []): Response + public function render( + string $template, + array $data = [], + ): Response { return Response::html(($this->renderFn)($template, $data)); } - public function renderToString(string $template, array $data = []): string + public function renderToString( + string $template, + array $data = [], + ): string { return ($this->renderFn)($template, $data); } @@ -196,6 +205,7 @@ function buildProcessor(ComponentCollection $collection, ViewInterface $view): L $view = stubView(function (string $template, array $data) use (&$rendered): string { $rendered[$template] = $data; + return '
'; }); @@ -214,6 +224,7 @@ function buildProcessor(ComponentCollection $collection, ViewInterface $view): L if ($template === 'layouts/root.html') { $slotsPassed = $data['slots'] ?? null; } + return '
'; }); @@ -232,6 +243,7 @@ function buildProcessor(ComponentCollection $collection, ViewInterface $view): L if ($template === 'layouts/root.html') { $layoutTemplateCalled = true; } + return '
'; }); @@ -284,6 +296,7 @@ className: LpFixtureSidebarComponent::class, if ($template !== 'layouts/root.html') { $renderOrder[] = $template; } + return '
'; }); @@ -311,6 +324,7 @@ className: LpFixtureSidebarComponent::class, $view = stubView(function (string $template, array $data) use (&$renderedData): string { $renderedData[$template] = $data; + return '
'; }); diff --git a/packages/layout/tests/Unit/Middleware/LayoutMiddlewareTest.php b/packages/layout/tests/Unit/Middleware/LayoutMiddlewareTest.php index 7b0aa056..4b9548db 100644 --- a/packages/layout/tests/Unit/Middleware/LayoutMiddlewareTest.php +++ b/packages/layout/tests/Unit/Middleware/LayoutMiddlewareTest.php @@ -23,6 +23,7 @@ class LmFixtureController public function index(): string { $this->actionInvoked = true; + return 'controller response'; } } @@ -68,7 +69,10 @@ function stubMatcher(?MatchedRoute $matched): RouteMatcherInterface { public function __construct(private readonly ?MatchedRoute $stub) {} - public function match(string $method, string $path): ?MatchedRoute + public function match( + string $method, + string $path, + ): ?MatchedRoute { return $this->stub; } diff --git a/packages/mail/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/mail/tests/Unit/Exceptions/NoDriverExceptionTest.php index 9c1de9a7..23eff5aa 100644 --- a/packages/mail/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/mail/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -60,7 +60,9 @@ it('includes context about resolving mail interfaces', function (): void { $exception = NoDriverException::noDriverInstalled(); - expect($exception->getContext())->toBe('Attempted to resolve a mail interface but no implementation is bound.'); + expect($exception->getContext())->toBe( + 'Attempted to resolve a mail interface but no implementation is bound.' + ); }); it('extends MarkoException', function (): void { diff --git a/packages/notification/tests/KnownDriversValidationTest.php b/packages/notification/tests/KnownDriversValidationTest.php index 297fc19f..6a94a268 100644 --- a/packages/notification/tests/KnownDriversValidationTest.php +++ b/packages/notification/tests/KnownDriversValidationTest.php @@ -7,9 +7,12 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all notification drivers', function () use ($knownDriversPath, $skeletonComposerPath) { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); -}); +test( + 'skeleton suggest block contains all notification drivers', + function () use ($knownDriversPath, $skeletonComposerPath) { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); + } +); test('every notification driver follows marko slash prefix pattern', function () use ($knownDriversPath) { KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); diff --git a/packages/page-cache/tests/KnownDriversValidationTest.php b/packages/page-cache/tests/KnownDriversValidationTest.php index f8f376c6..582a7c6a 100644 --- a/packages/page-cache/tests/KnownDriversValidationTest.php +++ b/packages/page-cache/tests/KnownDriversValidationTest.php @@ -7,5 +7,11 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all page-cache drivers', fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)); -test('every page-cache driver follows marko slash prefix pattern', fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)); +test( + 'skeleton suggest block contains all page-cache drivers', + fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath) +); +test( + 'every page-cache driver follows marko slash prefix pattern', + fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath) +); diff --git a/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php index c3f3fd55..95bd9a96 100644 --- a/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -13,9 +13,12 @@ ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/page-cache-file/'); }); -it('page-cache NoDriverException exposes a noDriverInstalled() factory (renamed from noBinding for consistency)', function (): void { - $exception = NoDriverException::noDriverInstalled(); - - expect($exception)->toBeInstanceOf(NoDriverException::class) - ->and($exception)->toBeInstanceOf(PageCacheException::class); -}); +it( + 'page-cache NoDriverException exposes a noDriverInstalled() factory (renamed from noBinding for consistency)', + function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception)->toBeInstanceOf(NoDriverException::class) + ->and($exception)->toBeInstanceOf(PageCacheException::class); + } +); diff --git a/packages/pubsub-pgsql/src/Driver/PgSqlPublisher.php b/packages/pubsub-pgsql/src/Driver/PgSqlPublisher.php index 88d8a69c..d29d9ac0 100644 --- a/packages/pubsub-pgsql/src/Driver/PgSqlPublisher.php +++ b/packages/pubsub-pgsql/src/Driver/PgSqlPublisher.php @@ -16,7 +16,10 @@ public function __construct( private PubSubConfig $config, ) {} - public function publish(string $channel, Message $message): void + public function publish( + string $channel, + Message $message, + ): void { $prefixed = $this->config->prefix() . $channel; $this->connection->connection()->notify($prefixed, $message->payload); diff --git a/packages/pubsub-pgsql/tests/Driver/PgSqlPublisherTest.php b/packages/pubsub-pgsql/tests/Driver/PgSqlPublisherTest.php index aad33450..c2d45081 100644 --- a/packages/pubsub-pgsql/tests/Driver/PgSqlPublisherTest.php +++ b/packages/pubsub-pgsql/tests/Driver/PgSqlPublisherTest.php @@ -24,7 +24,10 @@ class PublisherStubPostgresConnection implements PostgresConnection /** @var array */ public array $notifyCalls = []; - public function notify(string $channel, string $payload = ''): PostgresResult + public function notify( + string $channel, + string $payload = '', + ): PostgresResult { $this->notifyCalls[] = ['channel' => $channel, 'payload' => $payload]; @@ -51,7 +54,10 @@ public function prepare(string $sql): PostgresStatement throw new RuntimeException('Not implemented in stub'); } - public function execute(string $sql, array $params = []): PostgresResult + public function execute( + string $sql, + array $params = [], + ): PostgresResult { throw new RuntimeException('Not implemented in stub'); } diff --git a/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriberTest.php b/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriberTest.php index e2421849..762d7221 100644 --- a/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriberTest.php +++ b/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriberTest.php @@ -30,7 +30,10 @@ class MockPostgresListener implements PostgresListener, IteratorAggregate private string $channel; /** @param PostgresNotification[] $notifications */ - public function __construct(string $channel, array $notifications = []) + public function __construct( + string $channel, + array $notifications = [], + ) { $this->channel = $channel; $this->notifications = $notifications; @@ -82,7 +85,10 @@ public function listen(string $channel): PostgresListener return $listener; } - public function notify(string $channel, string $payload = ''): PostgresResult + public function notify( + string $channel, + string $payload = '', + ): PostgresResult { throw new RuntimeException('Not implemented in stub'); } @@ -102,7 +108,10 @@ public function prepare(string $sql): PostgresStatement throw new RuntimeException('Not implemented in stub'); } - public function execute(string $sql, array $params = []): PostgresResult + public function execute( + string $sql, + array $params = [], + ): PostgresResult { throw new RuntimeException('Not implemented in stub'); } diff --git a/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriptionTest.php b/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriptionTest.php index 01cb5866..bdec26e4 100644 --- a/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriptionTest.php +++ b/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriptionTest.php @@ -18,7 +18,10 @@ class SubscriptionMockPostgresListener implements PostgresListener, IteratorAggr private string $channel; /** @param PostgresNotification[] $notifications */ - public function __construct(string $channel, array $notifications = []) + public function __construct( + string $channel, + array $notifications = [], + ) { $this->channel = $channel; $this->notifications = $notifications; diff --git a/packages/pubsub-pgsql/tests/PgSqlPubSubConnectionTest.php b/packages/pubsub-pgsql/tests/PgSqlPubSubConnectionTest.php index cfe58232..48a22dbb 100644 --- a/packages/pubsub-pgsql/tests/PgSqlPubSubConnectionTest.php +++ b/packages/pubsub-pgsql/tests/PgSqlPubSubConnectionTest.php @@ -16,7 +16,10 @@ */ class ConnectionStubPostgresConnection implements PostgresConnection { - public function notify(string $channel, string $payload = ''): PostgresResult + public function notify( + string $channel, + string $payload = '', + ): PostgresResult { throw new RuntimeException('Not implemented in stub'); } @@ -41,7 +44,10 @@ public function prepare(string $sql): PostgresStatement throw new RuntimeException('Not implemented in stub'); } - public function execute(string $sql, array $params = []): PostgresResult + public function execute( + string $sql, + array $params = [], + ): PostgresResult { throw new RuntimeException('Not implemented in stub'); } diff --git a/packages/pubsub-redis/src/Driver/RedisPublisher.php b/packages/pubsub-redis/src/Driver/RedisPublisher.php index 6adfa125..87fd463b 100644 --- a/packages/pubsub-redis/src/Driver/RedisPublisher.php +++ b/packages/pubsub-redis/src/Driver/RedisPublisher.php @@ -16,7 +16,10 @@ public function __construct( private PubSubConfig $config, ) {} - public function publish(string $channel, Message $message): void + public function publish( + string $channel, + Message $message, + ): void { $prefixed = $this->config->prefix() . $channel; $this->connection->client()->publish($prefixed, $message->payload); diff --git a/packages/pubsub-redis/tests/Driver/RedisPublisherTest.php b/packages/pubsub-redis/tests/Driver/RedisPublisherTest.php index beec9af2..b49370d8 100644 --- a/packages/pubsub-redis/tests/Driver/RedisPublisherTest.php +++ b/packages/pubsub-redis/tests/Driver/RedisPublisherTest.php @@ -29,7 +29,10 @@ class PublisherTestStubRedisLink implements RedisLink /** @var array}> */ public array $calls = []; - public function execute(string $command, array $parameters): RedisResponse + public function execute( + string $command, + array $parameters, + ): RedisResponse { $this->calls[] = ['command' => $command, 'parameters' => $parameters]; diff --git a/packages/pubsub-redis/tests/Driver/RedisSubscriberTest.php b/packages/pubsub-redis/tests/Driver/RedisSubscriberTest.php index 704e968a..061b6800 100644 --- a/packages/pubsub-redis/tests/Driver/RedisSubscriberTest.php +++ b/packages/pubsub-redis/tests/Driver/RedisSubscriberTest.php @@ -30,7 +30,10 @@ class SpyAmphpRedisSubscriber implements AmphpRedisSubscriberInterface * @param array $channelSubscriptions * @param array $patternSubscriptions */ - public function __construct(array $channelSubscriptions = [], array $patternSubscriptions = []) + public function __construct( + array $channelSubscriptions = [], + array $patternSubscriptions = [], + ) { $this->channelSubscriptions = $channelSubscriptions; $this->patternSubscriptions = $patternSubscriptions; diff --git a/packages/pubsub-redis/tests/RedisPubSubConnectionTest.php b/packages/pubsub-redis/tests/RedisPubSubConnectionTest.php index d0102829..9bfafee7 100644 --- a/packages/pubsub-redis/tests/RedisPubSubConnectionTest.php +++ b/packages/pubsub-redis/tests/RedisPubSubConnectionTest.php @@ -27,7 +27,10 @@ class StubRedisLink implements RedisLink /** @var array}> */ public array $calls = []; - public function execute(string $command, array $parameters): RedisResponse + public function execute( + string $command, + array $parameters, + ): RedisResponse { $this->calls[] = ['command' => $command, 'parameters' => $parameters]; diff --git a/packages/pubsub/src/Exceptions/PubSubException.php b/packages/pubsub/src/Exceptions/PubSubException.php index 3fc4ce5d..c4cbde42 100644 --- a/packages/pubsub/src/Exceptions/PubSubException.php +++ b/packages/pubsub/src/Exceptions/PubSubException.php @@ -8,7 +8,10 @@ class PubSubException extends MarkoException { - public static function connectionFailed(string $driver, string $reason): self + public static function connectionFailed( + string $driver, + string $reason, + ): self { return new self( message: "Failed to connect to pub/sub driver '$driver'.", @@ -17,7 +20,10 @@ public static function connectionFailed(string $driver, string $reason): self ); } - public static function subscriptionFailed(string $channel, string $reason): self + public static function subscriptionFailed( + string $channel, + string $reason, + ): self { return new self( message: "Failed to subscribe to channel '$channel'.", @@ -26,7 +32,10 @@ public static function subscriptionFailed(string $channel, string $reason): self ); } - public static function publishFailed(string $channel, string $reason): self + public static function publishFailed( + string $channel, + string $reason, + ): self { return new self( message: "Failed to publish to channel '$channel'.", diff --git a/packages/pubsub/src/PublisherInterface.php b/packages/pubsub/src/PublisherInterface.php index 735ea4c3..f25272e6 100644 --- a/packages/pubsub/src/PublisherInterface.php +++ b/packages/pubsub/src/PublisherInterface.php @@ -9,5 +9,8 @@ interface PublisherInterface /** * Publish a message to a channel. */ - public function publish(string $channel, Message $message): void; + public function publish( + string $channel, + Message $message, + ): void; } diff --git a/packages/pubsub/tests/KnownDriversValidationTest.php b/packages/pubsub/tests/KnownDriversValidationTest.php index 1a1bd17e..0713ada2 100644 --- a/packages/pubsub/tests/KnownDriversValidationTest.php +++ b/packages/pubsub/tests/KnownDriversValidationTest.php @@ -7,5 +7,11 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all pubsub drivers', fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)); -test('every pubsub driver follows marko slash prefix pattern', fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)); +test( + 'skeleton suggest block contains all pubsub drivers', + fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath) +); +test( + 'every pubsub driver follows marko slash prefix pattern', + fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath) +); diff --git a/packages/pubsub/tests/MessageTest.php b/packages/pubsub/tests/MessageTest.php index e37c1558..a501cf0c 100644 --- a/packages/pubsub/tests/MessageTest.php +++ b/packages/pubsub/tests/MessageTest.php @@ -5,23 +5,26 @@ use Marko\PubSub\Message; describe('Message', function (): void { - it('creates readonly Message value object with channel, payload, and optional pattern properties', function (): void { - $reflection = new ReflectionClass(Message::class); - - expect($reflection->isReadOnly())->toBeTrue(); - - $channelProp = $reflection->getProperty('channel'); - $payloadProp = $reflection->getProperty('payload'); - $patternProp = $reflection->getProperty('pattern'); - - expect($channelProp->isPublic())->toBeTrue() - ->and($channelProp->getType()?->getName())->toBe('string') - ->and($payloadProp->isPublic())->toBeTrue() - ->and($payloadProp->getType()?->getName())->toBe('string') - ->and($patternProp->isPublic())->toBeTrue() - ->and($patternProp->getType()?->allowsNull())->toBeTrue() - ->and($patternProp->getType()?->getName())->toBe('string'); - }); + it( + 'creates readonly Message value object with channel, payload, and optional pattern properties', + function (): void { + $reflection = new ReflectionClass(Message::class); + + expect($reflection->isReadOnly())->toBeTrue(); + + $channelProp = $reflection->getProperty('channel'); + $payloadProp = $reflection->getProperty('payload'); + $patternProp = $reflection->getProperty('pattern'); + + expect($channelProp->isPublic())->toBeTrue() + ->and($channelProp->getType()?->getName())->toBe('string') + ->and($payloadProp->isPublic())->toBeTrue() + ->and($payloadProp->getType()?->getName())->toBe('string') + ->and($patternProp->isPublic())->toBeTrue() + ->and($patternProp->getType()?->allowsNull())->toBeTrue() + ->and($patternProp->getType()?->getName())->toBe('string'); + } + ); it('creates Message with all properties accessible', function (): void { $message = new Message( diff --git a/packages/pubsub/tests/SubscriberInterfaceTest.php b/packages/pubsub/tests/SubscriberInterfaceTest.php index 1c5825ef..c4c4cf3a 100644 --- a/packages/pubsub/tests/SubscriberInterfaceTest.php +++ b/packages/pubsub/tests/SubscriberInterfaceTest.php @@ -6,41 +6,47 @@ use Marko\PubSub\Subscription; describe('SubscriberInterface', function (): void { - it('defines SubscriberInterface with subscribe method accepting variadic channels returning Subscription', function (): void { - $reflection = new ReflectionClass(SubscriberInterface::class); - - expect($reflection->isInterface())->toBeTrue() - ->and($reflection->hasMethod('subscribe'))->toBeTrue(); - - $method = $reflection->getMethod('subscribe'); - - expect($method->isPublic())->toBeTrue() - ->and($method->getReturnType()?->getName())->toBe(Subscription::class) - ->and($method->getParameters())->toHaveCount(1); - - $channelsParam = $method->getParameters()[0]; - - expect($channelsParam->getName())->toBe('channels') - ->and($channelsParam->getType()?->getName())->toBe('string') - ->and($channelsParam->isVariadic())->toBeTrue(); - }); - - it('defines SubscriberInterface with psubscribe method accepting variadic patterns returning Subscription', function (): void { - $reflection = new ReflectionClass(SubscriberInterface::class); - - expect($reflection->isInterface())->toBeTrue() - ->and($reflection->hasMethod('psubscribe'))->toBeTrue(); - - $method = $reflection->getMethod('psubscribe'); - - expect($method->isPublic())->toBeTrue() - ->and($method->getReturnType()?->getName())->toBe(Subscription::class) - ->and($method->getParameters())->toHaveCount(1); - - $patternsParam = $method->getParameters()[0]; - - expect($patternsParam->getName())->toBe('patterns') - ->and($patternsParam->getType()?->getName())->toBe('string') - ->and($patternsParam->isVariadic())->toBeTrue(); - }); + it( + 'defines SubscriberInterface with subscribe method accepting variadic channels returning Subscription', + function (): void { + $reflection = new ReflectionClass(SubscriberInterface::class); + + expect($reflection->isInterface())->toBeTrue() + ->and($reflection->hasMethod('subscribe'))->toBeTrue(); + + $method = $reflection->getMethod('subscribe'); + + expect($method->isPublic())->toBeTrue() + ->and($method->getReturnType()?->getName())->toBe(Subscription::class) + ->and($method->getParameters())->toHaveCount(1); + + $channelsParam = $method->getParameters()[0]; + + expect($channelsParam->getName())->toBe('channels') + ->and($channelsParam->getType()?->getName())->toBe('string') + ->and($channelsParam->isVariadic())->toBeTrue(); + } + ); + + it( + 'defines SubscriberInterface with psubscribe method accepting variadic patterns returning Subscription', + function (): void { + $reflection = new ReflectionClass(SubscriberInterface::class); + + expect($reflection->isInterface())->toBeTrue() + ->and($reflection->hasMethod('psubscribe'))->toBeTrue(); + + $method = $reflection->getMethod('psubscribe'); + + expect($method->isPublic())->toBeTrue() + ->and($method->getReturnType()?->getName())->toBe(Subscription::class) + ->and($method->getParameters())->toHaveCount(1); + + $patternsParam = $method->getParameters()[0]; + + expect($patternsParam->getName())->toBe('patterns') + ->and($patternsParam->getType()?->getName())->toBe('string') + ->and($patternsParam->isVariadic())->toBeTrue(); + } + ); }); diff --git a/packages/queue/tests/KnownDriversValidationTest.php b/packages/queue/tests/KnownDriversValidationTest.php index 84bc53f7..1173c88e 100644 --- a/packages/queue/tests/KnownDriversValidationTest.php +++ b/packages/queue/tests/KnownDriversValidationTest.php @@ -19,12 +19,21 @@ ->and($drivers)->toHaveCount(3); }); -test('it lists marko/queue-sync first as the recommended development default', function () use ($knownDriversPath): void { - $drivers = require $knownDriversPath; - - expect(array_key_first($drivers))->toBe('marko/queue-sync'); -}); - -test('skeleton suggest block contains all queue drivers', fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)); - -test('every queue driver follows marko slash prefix pattern', fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)); +test( + 'it lists marko/queue-sync first as the recommended development default', + function () use ($knownDriversPath): void { + $drivers = require $knownDriversPath; + + expect(array_key_first($drivers))->toBe('marko/queue-sync'); + } +); + +test( + 'skeleton suggest block contains all queue drivers', + fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath) +); + +test( + 'every queue driver follows marko slash prefix pattern', + fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath) +); diff --git a/packages/routing/src/RouteMatcherInterface.php b/packages/routing/src/RouteMatcherInterface.php index 67c079a6..48476b80 100644 --- a/packages/routing/src/RouteMatcherInterface.php +++ b/packages/routing/src/RouteMatcherInterface.php @@ -6,5 +6,8 @@ interface RouteMatcherInterface { - public function match(string $method, string $path): ?MatchedRoute; + public function match( + string $method, + string $path, + ): ?MatchedRoute; } diff --git a/packages/session/tests/KnownDriversValidationTest.php b/packages/session/tests/KnownDriversValidationTest.php index 3691baf5..623814a3 100644 --- a/packages/session/tests/KnownDriversValidationTest.php +++ b/packages/session/tests/KnownDriversValidationTest.php @@ -7,9 +7,12 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all session drivers', function () use ($knownDriversPath, $skeletonComposerPath) { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); -}); +test( + 'skeleton suggest block contains all session drivers', + function () use ($knownDriversPath, $skeletonComposerPath) { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); + } +); test('every session driver follows marko slash prefix pattern', function () use ($knownDriversPath) { KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); diff --git a/packages/session/tests/Unit/Middleware/SessionMiddlewareTest.php b/packages/session/tests/Unit/Middleware/SessionMiddlewareTest.php index 9fab4b47..707e1ba1 100644 --- a/packages/session/tests/Unit/Middleware/SessionMiddlewareTest.php +++ b/packages/session/tests/Unit/Middleware/SessionMiddlewareTest.php @@ -141,12 +141,18 @@ public function save(): void } } - public function get(string $key, mixed $default = null): mixed + public function get( + string $key, + mixed $default = null, + ): mixed { return $default; } - public function set(string $key, mixed $value): void {} + public function set( + string $key, + mixed $value, + ): void {} public function has(string $key): bool { diff --git a/packages/skeleton/tests/KnownDriversSuggestParityTest.php b/packages/skeleton/tests/KnownDriversSuggestParityTest.php index ed868d45..57c560a3 100644 --- a/packages/skeleton/tests/KnownDriversSuggestParityTest.php +++ b/packages/skeleton/tests/KnownDriversSuggestParityTest.php @@ -40,7 +40,9 @@ )['suggest'] ?? []; expect($skeletonSuggest)->toHaveKey('marko/database-readwrite') - ->and($skeletonSuggest['marko/database-readwrite'])->toBe('Read/write connection splitting decorator (optional — works alongside a base driver)'); + ->and($skeletonSuggest['marko/database-readwrite'])->toBe( + 'Read/write connection splitting decorator (optional — works alongside a base driver)' + ); }); test('it includes marko/page-cache-entity as an optional add-on', function (): void { @@ -50,7 +52,9 @@ )['suggest'] ?? []; expect($skeletonSuggest)->toHaveKey('marko/page-cache-entity') - ->and($skeletonSuggest['marko/page-cache-entity'])->toBe('Auto-purges page-cache tags on entity save/delete (optional add-on)'); + ->and($skeletonSuggest['marko/page-cache-entity'])->toBe( + 'Auto-purges page-cache tags on entity save/delete (optional add-on)' + ); }); test('it does not move any view, database, cache, etc. drivers into require or require-dev', function (): void { diff --git a/packages/sse/src/SseStream.php b/packages/sse/src/SseStream.php index ece300be..83f96c91 100644 --- a/packages/sse/src/SseStream.php +++ b/packages/sse/src/SseStream.php @@ -45,6 +45,7 @@ public function getIterator(): Generator { if ($this->subscription !== null) { yield from $this->iterateSubscription(); + return; } @@ -60,7 +61,7 @@ private function iterateSubscription(): Generator $startTime = time(); foreach ($this->subscription as $message) { - if ((time() - $startTime) >= $this->timeout) { + if (time() - $startTime >= $this->timeout) { return; } @@ -96,7 +97,7 @@ private function iterateDataProvider(): Generator $lastHeartbeat = time(); } - if ((time() - $startTime) >= $this->timeout) { + if (time() - $startTime >= $this->timeout) { return; } diff --git a/packages/sse/tests/SseStreamTest.php b/packages/sse/tests/SseStreamTest.php index de2c69e1..aa4cc0b2 100644 --- a/packages/sse/tests/SseStreamTest.php +++ b/packages/sse/tests/SseStreamTest.php @@ -141,6 +141,7 @@ public function cancel(): void {} $stream = new SseStream( dataProvider: function () use (&$callCount): array { $callCount++; + return [new SseEvent(data: "event-$callCount")]; }, timeout: 0, @@ -157,6 +158,7 @@ public function cancel(): void {} $stream = new SseStream( dataProvider: function () use (&$callCount): array { $callCount++; + return $callCount <= 2 ? [new SseEvent(data: "event-$callCount")] : []; diff --git a/packages/testing/src/KnownDrivers/KnownDriversValidator.php b/packages/testing/src/KnownDrivers/KnownDriversValidator.php index 450e89ba..a6a4850b 100644 --- a/packages/testing/src/KnownDrivers/KnownDriversValidator.php +++ b/packages/testing/src/KnownDrivers/KnownDriversValidator.php @@ -28,8 +28,7 @@ private static function readKnownDrivers(string $knownDriversPath): array } /** - * @throws InvalidArgumentException - * @throws AssertionFailedError + * @throws InvalidArgumentException|AssertionFailedError */ public static function assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void { @@ -45,9 +44,7 @@ public static function assertDocsUrlsResolveToValidPattern(string $knownDriversP } /** - * @throws InvalidArgumentException - * @throws SkippedWithMessageException - * @throws AssertionFailedError + * @throws InvalidArgumentException|SkippedWithMessageException|AssertionFailedError */ public static function assertSkeletonSuggestContainsAll( string $knownDriversPath, diff --git a/packages/testing/tests/Unit/Exceptions/AssertionFailedExceptionTest.php b/packages/testing/tests/Unit/Exceptions/AssertionFailedExceptionTest.php index d5d8c8f9..e583adfd 100644 --- a/packages/testing/tests/Unit/Exceptions/AssertionFailedExceptionTest.php +++ b/packages/testing/tests/Unit/Exceptions/AssertionFailedExceptionTest.php @@ -5,18 +5,21 @@ use Marko\Core\Exceptions\MarkoException; use Marko\Testing\Exceptions\AssertionFailedException; -it('creates AssertionFailedException extending MarkoException with message, context, and suggestion', function (): void { - $exception = new AssertionFailedException( - message: 'Test assertion failed', - context: 'some context', - suggestion: 'some suggestion', - ); - - expect($exception)->toBeInstanceOf(MarkoException::class) - ->and($exception->getMessage())->toBe('Test assertion failed') - ->and($exception->getContext())->toBe('some context') - ->and($exception->getSuggestion())->toBe('some suggestion'); -}); +it( + 'creates AssertionFailedException extending MarkoException with message, context, and suggestion', + function (): void { + $exception = new AssertionFailedException( + message: 'Test assertion failed', + context: 'some context', + suggestion: 'some suggestion', + ); + + expect($exception)->toBeInstanceOf(MarkoException::class) + ->and($exception->getMessage())->toBe('Test assertion failed') + ->and($exception->getContext())->toBe('some context') + ->and($exception->getSuggestion())->toBe('some suggestion'); + } +); it('creates AssertionFailedException with static factory methods for common assertion failures', function (): void { expect(AssertionFailedException::expectedDispatched('MyEvent')) diff --git a/packages/translation/tests/Exceptions/NoDriverExceptionTest.php b/packages/translation/tests/Exceptions/NoDriverExceptionTest.php index 9f7dcce1..fa74e0f6 100644 --- a/packages/translation/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/translation/tests/Exceptions/NoDriverExceptionTest.php @@ -66,7 +66,9 @@ it('includes context about resolving translation interfaces', function (): void { $exception = NoDriverException::noDriverInstalled(); - expect($exception->getContext())->toContain('Attempted to resolve a translation interface but no implementation is bound.'); + expect($exception->getContext())->toContain( + 'Attempted to resolve a translation interface but no implementation is bound.' + ); }); it('extends TranslationException', function (): void { diff --git a/packages/translation/tests/KnownDriversValidationTest.php b/packages/translation/tests/KnownDriversValidationTest.php index e964ea90..eb1eca7e 100644 --- a/packages/translation/tests/KnownDriversValidationTest.php +++ b/packages/translation/tests/KnownDriversValidationTest.php @@ -7,5 +7,11 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all translation drivers', fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)); -test('every translation driver follows marko slash prefix pattern', fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)); +test( + 'skeleton suggest block contains all translation drivers', + fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath) +); +test( + 'every translation driver follows marko slash prefix pattern', + fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath) +); diff --git a/packages/view-latte/src/Extensions/SlotExtension.php b/packages/view-latte/src/Extensions/SlotExtension.php index 918b88bd..53e2530d 100644 --- a/packages/view-latte/src/Extensions/SlotExtension.php +++ b/packages/view-latte/src/Extensions/SlotExtension.php @@ -30,6 +30,7 @@ public static function create(Tag $tag): Generator $node = $tag->node = new static(); $node->name = $tag->parser->stream->consume()->text; yield; + return $node; } diff --git a/packages/view-latte/tests/LatteViewConfigTest.php b/packages/view-latte/tests/LatteViewConfigTest.php index 076f87b5..28780193 100644 --- a/packages/view-latte/tests/LatteViewConfigTest.php +++ b/packages/view-latte/tests/LatteViewConfigTest.php @@ -24,5 +24,5 @@ function (): void { $latteViewConfig = new LatteViewConfig($config); $latteViewConfig->strictTypes(); - } + }, )->throws(ConfigNotFoundException::class); diff --git a/packages/view-twig/src/ModuleLoader.php b/packages/view-twig/src/ModuleLoader.php index e0c32ceb..aca00c99 100644 --- a/packages/view-twig/src/ModuleLoader.php +++ b/packages/view-twig/src/ModuleLoader.php @@ -23,8 +23,7 @@ public function __construct( ) {} /** - * @throws LoaderError When $name does not use module namespace format or file cannot be read - * @throws TemplateNotFoundException When template cannot be found + * @throws LoaderError|TemplateNotFoundException */ public function getSourceContext(string $name): Source { @@ -46,8 +45,7 @@ public function getSourceContext(string $name): Source } /** - * @throws LoaderError When $name does not use module namespace format - * @throws TemplateNotFoundException When template cannot be found + * @throws LoaderError|TemplateNotFoundException */ public function getCacheKey(string $name): string { @@ -59,7 +57,10 @@ public function getCacheKey(string $name): string /** * @throws TemplateNotFoundException When template cannot be found */ - public function isFresh(string $name, int $time): bool + public function isFresh( + string $name, + int $time, + ): bool { $path = $this->resolvePath($name); diff --git a/packages/view-twig/src/TwigView.php b/packages/view-twig/src/TwigView.php index 47f5361f..bc075db9 100644 --- a/packages/view-twig/src/TwigView.php +++ b/packages/view-twig/src/TwigView.php @@ -8,6 +8,9 @@ use Marko\View\TemplateResolverInterface; use Marko\View\ViewInterface; use Twig\Environment; +use Twig\Error\LoaderError; +use Twig\Error\RuntimeError; +use Twig\Error\SyntaxError; class TwigView implements ViewInterface { @@ -21,9 +24,7 @@ public function __construct( /** * @param array $data - * @throws \Twig\Error\LoaderError - * @throws \Twig\Error\RuntimeError - * @throws \Twig\Error\SyntaxError + * @throws LoaderError|RuntimeError|SyntaxError */ public function render( string $template, @@ -36,9 +37,7 @@ public function render( /** * @param array $data - * @throws \Twig\Error\LoaderError - * @throws \Twig\Error\RuntimeError - * @throws \Twig\Error\SyntaxError + * @throws LoaderError|RuntimeError|SyntaxError */ public function renderToString( string $template, diff --git a/packages/view-twig/tests/TwigEngineFactoryTest.php b/packages/view-twig/tests/TwigEngineFactoryTest.php index c0564e03..73194fa9 100644 --- a/packages/view-twig/tests/TwigEngineFactoryTest.php +++ b/packages/view-twig/tests/TwigEngineFactoryTest.php @@ -6,6 +6,7 @@ use Marko\View\Twig\TwigEngineFactory; use Marko\View\Twig\TwigViewConfig; use Twig\Environment; +use Twig\Extension\EscaperExtension; function makeTwigViewConfig(array $overrides = []): TwigViewConfig { @@ -55,7 +56,7 @@ function makeTwigViewConfig(array $overrides = []): TwigViewConfig $factory = new TwigEngineFactory(makeTwigViewConfig(['view.autoescape' => 'js'])); $env = $factory->create(); - $escaper = $env->getExtension(\Twig\Extension\EscaperExtension::class); + $escaper = $env->getExtension(EscaperExtension::class); expect($escaper->getDefaultStrategy('template.html.twig'))->toBe('js'); }); diff --git a/packages/view/tests/Exceptions/NoDriverExceptionTest.php b/packages/view/tests/Exceptions/NoDriverExceptionTest.php index 4508bce4..fec7b508 100644 --- a/packages/view/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/view/tests/Exceptions/NoDriverExceptionTest.php @@ -77,7 +77,9 @@ it('includes context about resolving ViewInterface', function (): void { $exception = NoDriverException::noDriverInstalled(); - expect($exception->getContext())->toContain('Attempted to resolve ViewInterface but no implementation is bound.'); + expect($exception->getContext())->toContain( + 'Attempted to resolve ViewInterface but no implementation is bound.' + ); }); it('extends ViewException', function (): void { diff --git a/packages/view/tests/Feature/CrossEngineTemplateParityTest.php b/packages/view/tests/Feature/CrossEngineTemplateParityTest.php index 2d709a22..688e1960 100644 --- a/packages/view/tests/Feature/CrossEngineTemplateParityTest.php +++ b/packages/view/tests/Feature/CrossEngineTemplateParityTest.php @@ -24,7 +24,9 @@ function extractEngineSuffix(string $packageName, string $parentName): ?string } if (!is_dir($packagesDir) || basename($packagesDir) !== 'packages') { - $this->markTestSkipped('Not running inside the monorepo packages directory — skipping cross-engine parity check.'); + $this->markTestSkipped( + 'Not running inside the monorepo packages directory — skipping cross-engine parity check.' + ); } $engines = require $enginesPath; @@ -60,7 +62,10 @@ function extractEngineSuffix(string $packageName, string $parentName): ?string foreach ($providers as $parent => $foundEngines) { foreach ($engines as $engineName => $engineMeta) { $description = $engineMeta['description'] ?? $engineName; - $message = "Parent module '$parent' has template providers for [" . implode(', ', array_keys($foundEngines)) + $message = "Parent module '$parent' has template providers for [" . implode( + ', ', + array_keys($foundEngines) + ) . "] but is missing a provider for engine '$engineName' ($description). " . "Expected a package like 'marko/" . basename($parent) . "-$engineName' declaring " . "extra.marko.templates_for: '$parent'."; @@ -165,17 +170,20 @@ function extractEngineSuffix(string $packageName, string $parentName): ?string ->and(extractEngineSuffix('marko/admin', 'marko/admin-panel'))->toBeNull(); }); -test('it ignores packages whose extracted suffix is not in known-engines (e.g., admin-panel-twig-extra produces suffix twig-extra and is skipped if not registered)', function () { - $enginesPath = dirname(__DIR__, 2) . '/known-engines.php'; - - if (!file_exists($enginesPath)) { - $this->markTestSkipped('known-engines.php not found — marko/view not installed standalone?'); +test( + 'it ignores packages whose extracted suffix is not in known-engines (e.g., admin-panel-twig-extra produces suffix twig-extra and is skipped if not registered)', + function () { + $enginesPath = dirname(__DIR__, 2) . '/known-engines.php'; + + if (!file_exists($enginesPath)) { + $this->markTestSkipped('known-engines.php not found — marko/view not installed standalone?'); + } + + $engines = require $enginesPath; + + $suffix = extractEngineSuffix('marko/admin-panel-twig-extra', 'marko/admin-panel'); + + expect($suffix)->toBe('twig-extra') + ->and(isset($engines[$suffix]))->toBeFalse(); } - - $engines = require $enginesPath; - - $suffix = extractEngineSuffix('marko/admin-panel-twig-extra', 'marko/admin-panel'); - - expect($suffix)->toBe('twig-extra') - ->and(isset($engines[$suffix]))->toBeFalse(); -}); +); diff --git a/packages/view/tests/KnownDriversValidationTest.php b/packages/view/tests/KnownDriversValidationTest.php index dc00cc4f..2ee95b86 100644 --- a/packages/view/tests/KnownDriversValidationTest.php +++ b/packages/view/tests/KnownDriversValidationTest.php @@ -7,9 +7,12 @@ $knownDriversPath = __DIR__ . '/../known-drivers.php'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('skeleton suggest block contains all view drivers', function () use ($knownDriversPath, $skeletonComposerPath): void { - KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); -}); +test( + 'skeleton suggest block contains all view drivers', + function () use ($knownDriversPath, $skeletonComposerPath): void { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); + } +); test('every view driver follows marko slash prefix pattern', function () use ($knownDriversPath): void { KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); diff --git a/packages/view/tests/PackageStructureTest.php b/packages/view/tests/PackageStructureTest.php index 4b001ad0..b31e4c2b 100644 --- a/packages/view/tests/PackageStructureTest.php +++ b/packages/view/tests/PackageStructureTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); - it('composer.json exists with correct namespace', function (): void { $composerPath = dirname(__DIR__) . '/composer.json'; diff --git a/packages/webhook/tests/Jobs/DispatchWebhookJobRetryTest.php b/packages/webhook/tests/Jobs/DispatchWebhookJobRetryTest.php index bb90c5eb..48cbe94f 100644 --- a/packages/webhook/tests/Jobs/DispatchWebhookJobRetryTest.php +++ b/packages/webhook/tests/Jobs/DispatchWebhookJobRetryTest.php @@ -27,32 +27,51 @@ $httpClient = new class () implements HttpClientInterface { - public function request(string $method, string $url, array $options = []): HttpResponse + public function request( + string $method, + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } - public function get(string $url, array $options = []): HttpResponse + public function get( + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } - public function post(string $url, array $options = []): HttpResponse + public function post( + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } - public function put(string $url, array $options = []): HttpResponse + public function put( + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } - public function patch(string $url, array $options = []): HttpResponse + public function patch( + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } - public function delete(string $url, array $options = []): HttpResponse + public function delete( + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } @@ -108,32 +127,51 @@ public function save( $httpClient = new class () implements HttpClientInterface { - public function request(string $method, string $url, array $options = []): HttpResponse + public function request( + string $method, + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } - public function get(string $url, array $options = []): HttpResponse + public function get( + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } - public function post(string $url, array $options = []): HttpResponse + public function post( + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } - public function put(string $url, array $options = []): HttpResponse + public function put( + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } - public function patch(string $url, array $options = []): HttpResponse + public function patch( + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } - public function delete(string $url, array $options = []): HttpResponse + public function delete( + string $url, + array $options = [], + ): HttpResponse { throw new RuntimeException('Connection timed out'); } diff --git a/packages/webhook/tests/Jobs/DispatchWebhookJobTest.php b/packages/webhook/tests/Jobs/DispatchWebhookJobTest.php index 02b50156..fc4fd631 100644 --- a/packages/webhook/tests/Jobs/DispatchWebhookJobTest.php +++ b/packages/webhook/tests/Jobs/DispatchWebhookJobTest.php @@ -29,34 +29,53 @@ { public bool $postCalled = false; - public function request(string $method, string $url, array $options = []): HttpResponse + public function request( + string $method, + string $url, + array $options = [], + ): HttpResponse { return new HttpResponse(200, 'OK'); } - public function get(string $url, array $options = []): HttpResponse + public function get( + string $url, + array $options = [], + ): HttpResponse { return new HttpResponse(200, 'OK'); } - public function post(string $url, array $options = []): HttpResponse + public function post( + string $url, + array $options = [], + ): HttpResponse { $this->postCalled = true; return new HttpResponse(200, 'OK'); } - public function put(string $url, array $options = []): HttpResponse + public function put( + string $url, + array $options = [], + ): HttpResponse { return new HttpResponse(200, 'OK'); } - public function patch(string $url, array $options = []): HttpResponse + public function patch( + string $url, + array $options = [], + ): HttpResponse { return new HttpResponse(200, 'OK'); } - public function delete(string $url, array $options = []): HttpResponse + public function delete( + string $url, + array $options = [], + ): HttpResponse { return new HttpResponse(200, 'OK'); } diff --git a/phpcs.xml b/phpcs.xml index c025187d..26dc798a 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -3,8 +3,6 @@ Marko Framework Coding Standard packages - demo/app - demo/modules */vendor/* diff --git a/tests/SplitWorkflowTest.php b/tests/SplitWorkflowTest.php index 119baee4..74746116 100644 --- a/tests/SplitWorkflowTest.php +++ b/tests/SplitWorkflowTest.php @@ -35,9 +35,9 @@ expect($workflowContent)->toContain('refs/heads/${BRANCH}'); }); -it('uses SPLIT_TOKEN secret for authentication', function () use ($workflowContent): void { - expect($workflowContent)->toContain('secrets.SPLIT_TOKEN') - ->toContain('SPLIT_TOKEN'); +it('uses MARKO_BUILD_PAT secret for authentication', function () use ($workflowContent): void { + expect($workflowContent)->toContain('secrets.MARKO_BUILD_PAT') + ->toContain('MARKO_BUILD_PAT'); }); it('configures the target organization as an environment variable for easy changes', function () use ($workflowContent): void {