diff --git a/.travis.yml b/.travis.yml index 2e2de052182e2..9b87cfd5342f8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -64,6 +64,9 @@ before_install: set -e stty cols 120 mkdir /tmp/slapd + if [ ! -e /tmp/slapd-modules ]; then + [ -d /usr/lib/openldap ] && ln -s /usr/lib/openldap /tmp/slapd-modules || ln -s /usr/lib/ldap /tmp/slapd-modules + fi slapd -f src/Symfony/Component/Ldap/Tests/Fixtures/conf/slapd.conf -h ldap://localhost:3389 & [ -d ~/.composer ] || mkdir ~/.composer cp .composer/* ~/.composer/ diff --git a/UPGRADE-4.3.md b/UPGRADE-4.3.md new file mode 100644 index 0000000000000..3f5f22164dfaa --- /dev/null +++ b/UPGRADE-4.3.md @@ -0,0 +1,302 @@ +UPGRADE FROM 4.2 to 4.3 +======================= + +BrowserKit +---------- + + * Renamed `Client` to `AbstractBrowser` + * Marked `Response` final. + * Deprecated `Response::buildHeader()` + * Deprecated `Response::getStatus()`, use `Response::getStatusCode()` instead + +Cache +----- + + * The `psr/simple-cache` dependency has been removed - run `composer require psr/simple-cache` if you need it. + * Deprecated all PSR-16 adapters, use `Psr16Cache` or `Symfony\Contracts\Cache\CacheInterface` implementations instead. + * Deprecated `SimpleCacheAdapter`, use `Psr16Adapter` instead. + +Config +------ + + * Deprecated using environment variables with `cannotBeEmpty()` if the value is validated with `validate()` + * Deprecated the `root()` method in `TreeBuilder`, pass the root node information to the constructor instead + +DependencyInjection +------------------- + + * Deprecated support for non-string default env() parameters + + Before: + ```yaml + parameters: + env(NAME): 1.5 + ``` + + After: + ```yaml + parameters: + env(NAME): '1.5' + ``` + +Doctrine Bridge +--------------- + + * Passing an `IdReader` to the `DoctrineChoiceLoader` when the query cannot be optimized with single id field has been deprecated, pass `null` instead + * Not passing an `IdReader` to the `DoctrineChoiceLoader` when the query can be optimized with single id field has been deprecated + +Dotenv +------ + + * First parameter of `Dotenv::__construct()` will be changed from `true` to `false` in Symfony 5.0. A deprecation warning + is triggered if no parameter is provided. Use `$usePutenv = true` to upgrade without breaking changes. + +EventDispatcher +--------------- + + * The signature of the `EventDispatcherInterface::dispatch()` method should be updated to `dispatch($event, string $eventName = null)`, not doing so is deprecated + * The `Event` class has been deprecated, use `Symfony\Contracts\EventDispatcher\Event` instead + +Form +---- + + * Using the `format` option of `DateType` and `DateTimeType` when the `html5` option is enabled is deprecated. + * Using names for buttons that do not start with a letter, a digit, or an underscore is deprecated and will lead to an + exception in 5.0. + * Using names for buttons that do not contain only letters, digits, underscores, hyphens, and colons is deprecated and + will lead to an exception in 5.0. + * Using the `date_format`, `date_widget`, and `time_widget` options of the `DateTimeType` when the `widget` option is + set to `single_text` is deprecated. + +FrameworkBundle +--------------- + + * Not passing the project directory to the constructor of the `AssetsInstallCommand` is deprecated. This argument will + be mandatory in 5.0. + * Deprecated the "Psr\SimpleCache\CacheInterface" / "cache.app.simple" service, use "Symfony\Contracts\Cache\CacheInterface" / "cache.app" instead. + * The `generate()` method of the `UrlGenerator` class can return an empty string instead of null. + +HttpFoundation +-------------- + + * The `MimeTypeGuesserInterface` and `ExtensionGuesserInterface` interfaces have been deprecated, + use `Symfony\Component\Mime\MimeTypesInterface` instead. + * The `MimeType` and `MimeTypeExtensionGuesser` classes have been deprecated, + use `Symfony\Component\Mime\MimeTypes` instead. + * The `FileBinaryMimeTypeGuesser` class has been deprecated, + use `Symfony\Component\Mime\FileBinaryMimeTypeGuesser` instead. + * The `FileinfoMimeTypeGuesser` class has been deprecated, + use `Symfony\Component\Mime\FileinfoMimeTypeGuesser` instead. + +HttpKernel +---------- + + * Renamed `Client` to `HttpKernelBrowser` + * Renamed `FilterControllerArgumentsEvent` to `ControllerArgumentsEvent` + * Renamed `FilterControllerEvent` to `ControllerEvent` + * Renamed `FilterResponseEvent` to `ResponseEvent` + * Renamed `GetResponseEvent` to `RequestEvent` + * Renamed `GetResponseForControllerResultEvent` to `ViewEvent` + * Renamed `GetResponseForExceptionEvent` to `ExceptionEvent` + * Renamed `PostResponseEvent` to `TerminateEvent` + * Deprecated `TranslatorListener` in favor of `LocaleAwareListener` + +Intl +---- + + * Deprecated `ResourceBundle` namespace + * Deprecated `Intl::getCurrencyBundle()`, use `Currencies` instead + * Deprecated `Intl::getLanguageBundle()`, use `Languages` or `Scripts` instead + * Deprecated `Intl::getLocaleBundle()`, use `Locales` instead + * Deprecated `Intl::getRegionBundle()`, use `Regions` instead + +Messenger +--------- + + * `Amqp` transport does not throw `\AMQPException` anymore, catch `TransportException` instead. + * Deprecated the `LoggingMiddleware` class, pass a logger to `SendMessageMiddleware` instead. + +Routing +------- + + * The `generator_base_class`, `generator_cache_class`, `matcher_base_class`, and `matcher_cache_class` router + options have been deprecated. + * Implementing `Serializable` for `Route` and `CompiledRoute` is deprecated; if you serialize them, please + ensure your unserialization logic can recover from a failure related to an updated serialization format + +Security +-------- + + * The `Role` and `SwitchUserRole` classes are deprecated and will be removed in 5.0. Use strings for roles + instead. + * The `getReachableRoles()` method of the `RoleHierarchyInterface` is deprecated and will be removed in 5.0. + Role hierarchies must implement the `getReachableRoleNames()` method instead and return roles as strings. + * The `getRoles()` method of the `TokenInterface` is deprecated. Tokens must implement the `getRoleNames()` + method instead and return roles as strings. + * The `ListenerInterface` is deprecated, turn your listeners into callables instead. + * The `Firewall::handleRequest()` method is deprecated, use `Firewall::callListeners()` instead. + * The `AbstractToken::serialize()`, `AbstractToken::unserialize()`, + `AuthenticationException::serialize()` and `AuthenticationException::unserialize()` + methods are now final, use `__serialize()` and `__unserialize()` instead. + + Before: + ```php + public function serialize() + { + return [$this->myLocalVar, parent::serialize()]; + } + + public function unserialize($serialized) + { + [$this->myLocalVar, $parentSerialized] = unserialize($serialized); + parent::unserialize($parentSerialized); + } + ``` + + After: + ```php + public function __serialize(): array + { + return [$this->myLocalVar, parent::__serialize()]; + } + + public function __unserialize(array $data): void + { + [$this->myLocalVar, $parentData] = $data; + parent::__unserialize($parentData); + } + ``` + + * The `Argon2iPasswordEncoder` class has been deprecated, use `SodiumPasswordEncoder` instead. + * The `BCryptPasswordEncoder` class has been deprecated, use `NativePasswordEncoder` instead. + * Not implementing the methods `__serialize` and `__unserialize` in classes implementing + the `TokenInterface` is deprecated + +SecurityBundle +-------------- + + * Configuring encoders using `argon2i` or `bcrypt` as algorithm has been deprecated, use `auto` instead. + +TwigBridge +---------- + + * deprecated the `$requestStack` and `$requestContext` arguments of the + `HttpFoundationExtension`, pass a `Symfony\Component\HttpFoundation\UrlHelper` + instance as the only argument instead + +Workflow +-------- + + * `initial_place` is deprecated in favour of `initial_marking`. + + Before: + ```yaml + framework: + workflows: + article: + initial_place: draft + ``` + + After: + ```yaml + framework: + workflows: + article: + initial_marking: [draft] + ``` + + * `MarkingStoreInterface::setMarking()` will have a third argument in Symfony 5.0. + + Before: + ```php + class MyMarkingStore implements MarkingStoreInterface + { + public function setMarking($subject, Marking $marking) + { + } + } + ``` + + After: + ```php + class MyMarkingStore implements MarkingStoreInterface + { + public function setMarking($subject, Marking $marking , array $context = []) + { + } + } + ``` + + * `MultipleStateMarkingStore` is deprecated. Use `MethodMarkingStore` instead. + + Before: + ```yaml + framework: + workflows: + type: workflow + article: + marking_store: + type: multiple + arguments: states + ``` + + After: + ```yaml + framework: + workflows: + type: workflow + article: + marking_store: + type: method + property: states + ``` + + * `SingleStateMarkingStore` is deprecated. Use `MethodMarkingStore` instead. + + Before: + ```yaml + framework: + workflows: + article: + marking_store: + arguments: state + ``` + + After: + ```yaml + framework: + workflows: + type: state_machine + article: + marking_store: + type: method + property: state + ``` + + * Using a workflow with a single state marking is deprecated. Use a state machine instead. + + Before: + ```yaml + framework: + workflows: + article: + type: workflow + marking_store: + type: single_state + ``` + + After: + ```yaml + framework: + workflows: + article: + type: state_machine + marking_store: + # type: single_state # Since the single_state marking store is deprecated, use method instead + type: method + ``` + +Yaml +---- + + * Using a mapping inside a multi-line string is deprecated and will throw a `ParseException` in 5.0. diff --git a/UPGRADE-5.0.md b/UPGRADE-5.0.md index 4ec9e24f4f0ce..7c409d4630d2d 100644 --- a/UPGRADE-5.0.md +++ b/UPGRADE-5.0.md @@ -4,12 +4,18 @@ UPGRADE FROM 4.x to 5.0 BrowserKit ---------- + * Removed `Client`, use `AbstractBrowser` instead + * Removed the possibility to extend `Response` by making it final. + * Removed `Response::buildHeader()` + * Removed `Response::getStatus()`, use `Response::getStatusCode()` instead * The `Client::submit()` method has a new `$serverParameters` argument. Cache ----- * Removed `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead. + * Removed all PSR-16 adapters, use `Psr16Cache` or `Symfony\Contracts\Cache\CacheInterface` implementations instead. + * Removed `SimpleCacheAdapter`, use `Psr16Adapter` instead. Config ------ @@ -18,6 +24,8 @@ Config * Added the `getChildNodeDefinitions()` method to `ParentNodeDefinitionInterface`. * The `Processor` class has been made final * Removed `FileLoaderLoadException`, use `LoaderLoadException` instead. + * Using environment variables with `cannotBeEmpty()` if the value is validated with `validate()` will throw an exception. + * Removed the `root()` method in `TreeBuilder`, pass the root node information to the constructor instead Console ------- @@ -53,16 +61,49 @@ DoctrineBridge * Deprecated injecting `ClassMetadataFactory` in `DoctrineExtractor`, an instance of `EntityManagerInterface` should be injected instead + * Passing an `IdReader` to the `DoctrineChoiceLoader` when the query cannot be optimized with single id field will throw an exception, pass `null` instead + * Not passing an `IdReader` to the `DoctrineChoiceLoader` when the query can be optimized with single id field will throw an exception + DomCrawler ---------- * The `Crawler::children()` method has a new `$selector` argument. +Dotenv +------ + + * First parameter `$usePutenv` of `Dotenv::__construct()` now default to `false`. + EventDispatcher --------------- * The `TraceableEventDispatcherInterface` has been removed. + * The signature of the `EventDispatcherInterface::dispatch()` method has been updated to `dispatch($event, string $eventName = null)` + * The `Event` class has been removed, use `Symfony\Contracts\EventDispatcher\Event` instead + +DependencyInjection +------------------- + + * Removed support for non-string default env() parameters + + Before: + ```yaml + parameters: + env(NAME): 1.5 + ``` + + After: + ```yaml + parameters: + env(NAME): '1.5' + ``` + +Filesystem +---------- + + * The `Filesystem::dumpFile()` method no longer supports arrays in the `$content` argument. + * The `Filesystem::appendToFile()` method no longer supports arrays in the `$content` argument. Finder ------ @@ -72,6 +113,12 @@ Finder Form ---- + * Removed support for using the `format` option of `DateType` and `DateTimeType` when the `html5` option is enabled. + * Using names for buttons that do not start with a letter, a digit, or an underscore leads to an exception. + * Using names for buttons that do not contain only letters, digits, underscores, hyphens, and colons leads to an + exception. + * Using the `date_format`, `date_widget`, and `time_widget` options of the `DateTimeType` when the `widget` option is + set to `single_text` is not supported anymore. * The `getExtendedType()` method was removed from the `FormTypeExtensionInterface`. It is replaced by the the static `getExtendedTypes()` method which must return an iterable of extended types. @@ -126,6 +173,8 @@ Form FrameworkBundle --------------- + * The project dir argument of the constructor of `AssetsInstallCommand` is required. + * Removed support for `bundle:controller:action` syntax to reference controllers. Use `serviceOrFqcn::method` instead where `serviceOrFqcn` is either the service ID when using controllers as services or the FQCN of the controller. @@ -157,6 +206,7 @@ FrameworkBundle * The `Templating\Helper\TranslatorHelper::transChoice()` method has been removed, use the `trans()` one instead with a `%count%` parameter. * Removed support for legacy translations directories `src/Resources/translations/` and `src/Resources//translations/`, use `translations/` instead. * Support for the legacy directory structure in `translation:update` and `debug:translation` commands has been removed. + * Removed the "Psr\SimpleCache\CacheInterface" / "cache.app.simple" service, use "Symfony\Contracts\Cache\CacheInterface" / "cache.app" instead. HttpFoundation -------------- @@ -166,15 +216,46 @@ HttpFoundation * The `getSession()` method of the `Request` class throws an exception when session is null. * The default value of the "$secure" and "$samesite" arguments of Cookie's constructor changed respectively from "false" to "null" and from "null" to "lax". + * The `MimeTypeGuesserInterface` and `ExtensionGuesserInterface` interfaces have been removed, + use `Symfony\Component\Mime\MimeTypesInterface` instead. + * The `MimeType` and `MimeTypeExtensionGuesser` classes have been removed, + use `Symfony\Component\Mime\MimeTypes` instead. + * The `FileBinaryMimeTypeGuesser` class has been removed, + use `Symfony\Component\Mime\FileBinaryMimeTypeGuesser` instead. + * The `FileinfoMimeTypeGuesser` class has been removed, + use `Symfony\Component\Mime\FileinfoMimeTypeGuesser` instead. HttpKernel ---------- + * Removed `Client`, use `HttpKernelBrowser` instead * The `Kernel::getRootDir()` and the `kernel.root_dir` parameter have been removed * The `KernelInterface::getName()` and the `kernel.name` parameter have been removed * Removed the first and second constructor argument of `ConfigDataCollector` - * Removed `ConfigDataCollector::getApplicationName()` + * Removed `ConfigDataCollector::getApplicationName()` * Removed `ConfigDataCollector::getApplicationVersion()` + * Removed `FilterControllerArgumentsEvent`, use `ControllerArgumentsEvent` instead + * Removed `FilterControllerEvent`, use `ControllerEvent` instead + * Removed `FilterResponseEvent`, use `ResponseEvent` instead + * Removed `GetResponseEvent`, use `RequestEvent` instead + * Removed `GetResponseForControllerResultEvent`, use `ViewEvent` instead + * Removed `GetResponseForExceptionEvent`, use `ExceptionEvent` instead + * Removed `PostResponseEvent`, use `TerminateEvent` instead + * Removed `TranslatorListener` in favor of `LocaleAwareListener` + +Intl +---- + + * Removed `ResourceBundle` namespace + * Removed `Intl::getLanguageBundle()`, use `Languages` or `Scripts` instead + * Removed `Intl::getCurrencyBundle()`, use `Currencies` instead + * Removed `Intl::getLocaleBundle()`, use `Locales` instead + * Removed `Intl::getRegionBundle()`, use `Regions` instead + +Messenger +--------- + + * The `LoggingMiddleware` class has been removed, pass a logger to `SendMessageMiddleware` instead. Monolog ------- @@ -200,9 +281,22 @@ Process $process = Process::fromShellCommandline('ls -l'); ``` +Routing +------- + + * The `generator_base_class`, `generator_cache_class`, `matcher_base_class`, and `matcher_cache_class` router + options have been removed. + * `Route` and `CompiledRoute` don't implement `Serializable` anymore; if you serialize them, please + ensure your unserialization logic can recover from a failure related to an updated serialization format + Security -------- + * The `Role` and `SwitchUserRole` classes have been removed. + * The `getReachableRoles()` method of the `RoleHierarchy` class has been removed. It has been replaced by the new + `getReachableRoleNames()` method. + * The `getRoles()` method has been removed from the `TokenInterface`. It has been replaced by the new + `getRoleNames()` method. * The `ContextListener::setLogoutOnUserChange()` method has been removed. * The `Symfony\Component\Security\Core\User\AdvancedUserInterface` has been removed. * The `ExpressionVoter::addExpressionLanguageProvider()` method has been removed. @@ -213,6 +307,44 @@ Security * `SimpleAuthenticatorInterface`, `SimpleFormAuthenticatorInterface`, `SimplePreAuthenticatorInterface`, `SimpleAuthenticationProvider`, `SimpleAuthenticationHandler`, `SimpleFormAuthenticationListener` and `SimplePreAuthenticationListener` have been removed. Use Guard instead. + * The `ListenerInterface` has been removed, turn your listeners into callables instead. + * The `Firewall::handleRequest()` method has been removed, use `Firewall::callListeners()` instead. + * `\Serializable` interface has been removed from `AbstractToken` and `AuthenticationException`, + thus `serialize()` and `unserialize()` aren't available. + Use `__serialize()` and `__unserialize()` instead. + + Before: + ```php + public function serialize() + { + return [$this->myLocalVar, parent::serialize()]; + } + + public function unserialize($serialized) + { + [$this->myLocalVar, $parentSerialized] = unserialize($serialized); + parent::unserialize($parentSerialized); + } + ``` + + After: + ```php + public function __serialize(): array + { + return [$this->myLocalVar, parent::__serialize()]; + } + + public function __unserialize(array $data): void + { + [$this->myLocalVar, $parentData] = $data; + parent::__unserialize($parentData); + } + ``` + + * The `Argon2iPasswordEncoder` class has been removed, use `SodiumPasswordEncoder` instead. + * The `BCryptPasswordEncoder` class has been removed, use `NativePasswordEncoder` instead. + * Classes implementing the `TokenInterface` must implement the two new methods + `__serialize` and `__unserialize` SecurityBundle -------------- @@ -228,6 +360,12 @@ SecurityBundle use Guard instead. * The `SimpleFormFactory` and `SimplePreAuthenticationFactory` classes have been removed, use Guard instead. + * The names of the cookies configured in the `logout.delete_cookies` option are + no longer normalized. If any of your cookie names has dashes they won't be + changed to underscores. + Before: `my-cookie` deleted the `my_cookie` cookie (with an underscore). + After: `my-cookie` deletes the `my-cookie` cookie (with a dash). + * Configuring encoders using `argon2i` or `bcrypt` as algorithm is not supported anymore, use `auto` instead. Serializer ---------- @@ -250,6 +388,13 @@ TwigBundle * The default value (`false`) of the `twig.strict_variables` configuration option has been changed to `%kernel.debug%`. * The `transchoice` tag and filter have been removed, use the `trans` ones instead with a `%count%` parameter. * Removed support for legacy templates directories `src/Resources/views/` and `src/Resources//views/`, use `templates/` and `templates/bundles//` instead. + +TwigBridge +---------- + + * removed the `$requestStack` and `$requestContext` arguments of the + `HttpFoundationExtension`, pass a `Symfony\Component\HttpFoundation\UrlHelper` + instance as the only argument instead Validator -------- @@ -272,3 +417,73 @@ Workflow * `add` method has been removed use `addWorkflow` method in `Workflow\Registry` instead. * `SupportStrategyInterface` has been removed, use `WorkflowSupportStrategyInterface` instead. * `ClassInstanceSupportStrategy` has been removed, use `InstanceOfSupportStrategy` instead. + * `MarkingStoreInterface::setMarking()` has a third argument: `array $context = []`. + * Removed support of `initial_place`. Use `initial_places` instead. + * `MultipleStateMarkingStore` has been removed. Use `MethodMarkingStore` instead. + + Before: + ```yaml + framework: + workflows: + type: workflow + article: + marking_store: + type: multiple + arguments: states + ``` + + After: + ```yaml + framework: + workflows: + type: workflow + article: + marking_store: + property: states + ``` + * `SingleStateMarkingStore` has been removed. Use `MethodMarkingStore` instead. + + Before: + ```yaml + framework: + workflows: + article: + marking_store: + arguments: state + ``` + + After: + ```yaml + framework: + workflows: + article: + marking_store: + property: state + ``` + + + * Support for using a workflow with a single state marking is dropped. Use a state machine instead. + + Before: + ```yaml + framework: + workflows: + article: + type: workflow + marking_store: + type: single_state + ``` + + After: + ```yaml + framework: + workflows: + article: + type: state_machine + ``` + +Yaml +---- + + * The parser is now stricter and will throw a `ParseException` when a + mapping is found inside a multi-line string. diff --git a/composer.json b/composer.json index b0fcede7bf746..62a7f751dccaf 100644 --- a/composer.json +++ b/composer.json @@ -28,11 +28,13 @@ "psr/link": "^1.0", "psr/log": "~1.0", "psr/simple-cache": "^1.0", - "symfony/contracts": "^1.0.2", + "symfony/contracts": "^1.1", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-icu": "~1.0", + "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php72": "~1.5" + "symfony/polyfill-php72": "~1.5", + "symfony/polyfill-php73": "^1.11" }, "replace": { "symfony/asset": "self.version", @@ -53,6 +55,7 @@ "symfony/finder": "self.version", "symfony/form": "self.version", "symfony/framework-bundle": "self.version", + "symfony/http-client": "self.version", "symfony/http-foundation": "self.version", "symfony/http-kernel": "self.version", "symfony/inflector": "self.version", @@ -60,6 +63,7 @@ "symfony/ldap": "self.version", "symfony/lock": "self.version", "symfony/messenger": "self.version", + "symfony/mime": "self.version", "symfony/monolog-bridge": "self.version", "symfony/options-resolver": "self.version", "symfony/process": "self.version", @@ -97,28 +101,23 @@ "doctrine/orm": "~2.4,>=2.4.5", "doctrine/reflection": "~1.0", "doctrine/doctrine-bundle": "~1.4", + "masterminds/html5": "^2.6", "monolog/monolog": "~1.11", + "nyholm/psr7": "^1.0", "ocramius/proxy-manager": "~0.4|~1.0|~2.0", "predis/predis": "~1.1", + "psr/http-client": "^1.0", "egulias/email-validator": "~1.2,>=1.2.8|~2.0", "symfony/phpunit-bridge": "~3.4|~4.0", "symfony/security-acl": "~2.8|~3.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0" }, "conflict": { + "masterminds/html5": "<2.6", "phpdocumentor/reflection-docblock": "<3.0||>=3.2.0,<3.2.2", "phpdocumentor/type-resolver": "<0.3.0", "phpunit/phpunit": "<5.4.3" }, - "provide": { - "psr/cache-implementation": "1.0", - "psr/container-implementation": "1.0", - "psr/log-implementation": "1.0", - "psr/simple-cache-implementation": "1.0", - "symfony/cache-contracts": "1.0", - "symfony/service-contracts": "1.0", - "symfony/translation-contracts": "1.0" - }, "autoload": { "psr-4": { "Symfony\\Bridge\\Doctrine\\": "src/Symfony/Bridge/Doctrine/", @@ -147,7 +146,7 @@ "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7e78dd8f75208..7313d16d25c70 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -19,6 +19,7 @@ + @@ -70,9 +71,10 @@ Doctrine\Common\Cache Symfony\Component\Cache Symfony\Component\Cache\Tests\Fixtures - Symfony\Component\Cache\Traits - Symfony\Component\Console - Symfony\Component\HttpFoundation + Symfony\Component\Cache\Tests\Traits + Symfony\Component\Cache\Traits + Symfony\Component\Console + Symfony\Component\HttpFoundation diff --git a/src/Symfony/Bridge/Doctrine/CHANGELOG.md b/src/Symfony/Bridge/Doctrine/CHANGELOG.md index c333361d4a37f..3bcf9a77dfe56 100644 --- a/src/Symfony/Bridge/Doctrine/CHANGELOG.md +++ b/src/Symfony/Bridge/Doctrine/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +4.3.0 +----- + + * changed guessing of DECIMAL to set the `input` option of `NumberType` to string + * deprecated not passing an `IdReader` to the `DoctrineChoiceLoader` when query can be optimized with a single id field + * deprecated passing an `IdReader` to the `DoctrineChoiceLoader` when entities have a composite id + 4.2.0 ----- diff --git a/src/Symfony/Bridge/Doctrine/Form/ChoiceList/DoctrineChoiceLoader.php b/src/Symfony/Bridge/Doctrine/Form/ChoiceList/DoctrineChoiceLoader.php index 0ed66f8c44f41..cd040d12a9b03 100644 --- a/src/Symfony/Bridge/Doctrine/Form/ChoiceList/DoctrineChoiceLoader.php +++ b/src/Symfony/Bridge/Doctrine/Form/ChoiceList/DoctrineChoiceLoader.php @@ -42,16 +42,33 @@ class DoctrineChoiceLoader implements ChoiceLoaderInterface * * @param ObjectManager $manager The object manager * @param string $class The class name of the loaded objects - * @param IdReader $idReader The reader for the object IDs + * @param IdReader|null $idReader The reader for the object IDs * @param EntityLoaderInterface|null $objectLoader The objects loader */ public function __construct(ObjectManager $manager, string $class, IdReader $idReader = null, EntityLoaderInterface $objectLoader = null) { $classMetadata = $manager->getClassMetadata($class); + if ($idReader && !$idReader->isSingleId()) { + @trigger_error(sprintf('Passing an instance of "%s" to "%s" with an entity class "%s" that has a composite id is deprecated since Symfony 4.3 and will throw an exception in 5.0.', IdReader::class, __CLASS__, $class), E_USER_DEPRECATED); + + // In Symfony 5.0 + // throw new \InvalidArgumentException(sprintf('The second argument `$idReader` of "%s" must be null when the query cannot be optimized because of composite id fields.', __METHOD__)); + } + + if ((5 > \func_num_args() || false !== func_get_arg(4)) && null === $idReader) { + $idReader = new IdReader($manager, $classMetadata); + + if ($idReader->isSingleId()) { + @trigger_error(sprintf('Not explicitly passing an instance of "%s" to "%s" when it can optimize single id entity "%s" has been deprecated in 4.3 and will not apply any optimization in 5.0.', IdReader::class, __CLASS__, $class), E_USER_DEPRECATED); + } else { + $idReader = null; + } + } + $this->manager = $manager; $this->class = $classMetadata->getName(); - $this->idReader = $idReader ?: new IdReader($manager, $classMetadata); + $this->idReader = $idReader; $this->objectLoader = $objectLoader; } @@ -83,7 +100,7 @@ public function loadValuesForChoices(array $choices, $value = null) // Optimize performance for single-field identifiers. We already // know that the IDs are used as values - $optimize = null === $value || \is_array($value) && $value[0] === $this->idReader; + $optimize = $this->idReader && (null === $value || \is_array($value) && $value[0] === $this->idReader); // Attention: This optimization does not check choices for existence if ($optimize && !$this->choiceList && $this->idReader->isSingleId()) { @@ -120,7 +137,7 @@ public function loadChoicesForValues(array $values, $value = null) // Optimize performance in case we have an object loader and // a single-field identifier - $optimize = null === $value || \is_array($value) && $this->idReader === $value[0]; + $optimize = $this->idReader && (null === $value || \is_array($value) && $this->idReader === $value[0]); if ($optimize && !$this->choiceList && $this->objectLoader && $this->idReader->isSingleId()) { $unorderedObjects = $this->objectLoader->getEntitiesByIds($this->idReader->getIdField(), $values); diff --git a/src/Symfony/Bridge/Doctrine/Form/DoctrineOrmTypeGuesser.php b/src/Symfony/Bridge/Doctrine/Form/DoctrineOrmTypeGuesser.php index 49dfd9bfbce6e..34fb04aed283e 100644 --- a/src/Symfony/Bridge/Doctrine/Form/DoctrineOrmTypeGuesser.php +++ b/src/Symfony/Bridge/Doctrine/Form/DoctrineOrmTypeGuesser.php @@ -75,6 +75,7 @@ public function guessType($class, $property) case 'time_immutable': return new TypeGuess('Symfony\Component\Form\Extension\Core\Type\TimeType', ['input' => 'datetime_immutable'], Guess::HIGH_CONFIDENCE); case Type::DECIMAL: + return new TypeGuess('Symfony\Component\Form\Extension\Core\Type\NumberType', ['input' => 'string'], Guess::MEDIUM_CONFIDENCE); case Type::FLOAT: return new TypeGuess('Symfony\Component\Form\Extension\Core\Type\NumberType', [], Guess::MEDIUM_CONFIDENCE); case Type::INTEGER: diff --git a/src/Symfony/Bridge/Doctrine/Form/Type/DoctrineType.php b/src/Symfony/Bridge/Doctrine/Form/Type/DoctrineType.php index 327e7abb05600..88f9cf9101c7d 100644 --- a/src/Symfony/Bridge/Doctrine/Form/Type/DoctrineType.php +++ b/src/Symfony/Bridge/Doctrine/Form/Type/DoctrineType.php @@ -11,6 +11,7 @@ namespace Symfony\Bridge\Doctrine\Form\Type; +use Doctrine\Common\Collections\Collection; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\Common\Persistence\ObjectManager; use Symfony\Bridge\Doctrine\Form\ChoiceList\DoctrineChoiceLoader; @@ -107,7 +108,7 @@ public function __construct(ManagerRegistry $registry) public function buildForm(FormBuilderInterface $builder, array $options) { - if ($options['multiple']) { + if ($options['multiple'] && interface_exists(Collection::class)) { $builder ->addEventSubscriber(new MergeDoctrineCollectionListener()) ->addViewTransformer(new CollectionToArrayTransformer(), true) @@ -149,7 +150,8 @@ public function configureOptions(OptionsResolver $resolver) $options['em'], $options['class'], $options['id_reader'], - $entityLoader + $entityLoader, + false ); if (null !== $hash) { @@ -161,13 +163,10 @@ public function configureOptions(OptionsResolver $resolver) }; $choiceName = function (Options $options) { - /** @var IdReader $idReader */ - $idReader = $options['id_reader']; - // If the object has a single-column, numeric ID, use that ID as // field name. We can only use numeric IDs as names, as we cannot // guarantee that a non-numeric ID contains a valid form name - if ($idReader->isIntId()) { + if ($options['id_reader'] instanceof IdReader && $options['id_reader']->isIntId()) { return [__CLASS__, 'createChoiceName']; } @@ -179,12 +178,9 @@ public function configureOptions(OptionsResolver $resolver) // are indexed by an incrementing integer. // Use the ID/incrementing integer as choice value. $choiceValue = function (Options $options) { - /** @var IdReader $idReader */ - $idReader = $options['id_reader']; - // If the entity has a single-column ID, use that ID as value - if ($idReader->isSingleId()) { - return [$idReader, 'getIdValue']; + if ($options['id_reader'] instanceof IdReader && $options['id_reader']->isSingleId()) { + return [$options['id_reader'], 'getIdValue']; } // Otherwise, an incrementing integer is used as value automatically @@ -238,7 +234,11 @@ public function configureOptions(OptionsResolver $resolver) $this->idReaders[$hash] = new IdReader($options['em'], $classMetadata); } - return $this->idReaders[$hash]; + if ($this->idReaders[$hash]->isSingleId()) { + return $this->idReaders[$hash]; + } + + return null; }; $resolver->setDefaults([ diff --git a/src/Symfony/Bridge/Doctrine/Test/DoctrineTestHelper.php b/src/Symfony/Bridge/Doctrine/Test/DoctrineTestHelper.php index 3f6ffeebb6e2d..24aa66a7dda46 100644 --- a/src/Symfony/Bridge/Doctrine/Test/DoctrineTestHelper.php +++ b/src/Symfony/Bridge/Doctrine/Test/DoctrineTestHelper.php @@ -13,9 +13,12 @@ use Doctrine\Common\Annotations\AnnotationReader; use Doctrine\Common\Cache\ArrayCache; +use Doctrine\Common\Persistence\Mapping\Driver\MappingDriverChain; +use Doctrine\Common\Persistence\Mapping\Driver\SymfonyFileLocator; use Doctrine\ORM\Configuration; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\Driver\AnnotationDriver; +use Doctrine\ORM\Mapping\Driver\XmlDriver; use PHPUnit\Framework\TestCase; /** @@ -67,6 +70,28 @@ public static function createTestConfiguration() return $config; } + /** + * @return Configuration + */ + public static function createTestConfigurationWithXmlLoader() + { + $config = static::createTestConfiguration(); + + $driverChain = new MappingDriverChain(); + $driverChain->addDriver( + new XmlDriver( + new SymfonyFileLocator( + [__DIR__.'/../Tests/Resources/orm' => 'Symfony\\Bridge\\Doctrine\\Tests\\Fixtures'], '.orm.xml' + ) + ), + 'Symfony\\Bridge\\Doctrine\\Tests\\Fixtures' + ); + + $config->setMetadataDriverImpl($driverChain); + + return $config; + } + /** * This class cannot be instantiated. */ diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/BaseUser.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/BaseUser.php new file mode 100644 index 0000000000000..cbd0ff44688eb --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/BaseUser.php @@ -0,0 +1,47 @@ +id = $id; + $this->username = $username; + } + + /** + * @return int + */ + public function getId() + { + return $this->id; + } + + /** + * @return string + */ + public function getUsername() + { + return $this->username; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/DoctrineLoaderEntity.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/DoctrineLoaderEntity.php new file mode 100644 index 0000000000000..4a92edec8fa14 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/DoctrineLoaderEntity.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * @ORM\Entity + * @UniqueEntity(fields={"alreadyMappedUnique"}) + * + * @author Kévin Dunglas + */ +class DoctrineLoaderEntity +{ + /** + * @ORM\Id + * @ORM\Column + */ + public $id; + + /** + * @ORM\Column(length=20) + */ + public $maxLength; + + /** + * @ORM\Column(length=20) + * @Assert\Length(min=5) + */ + public $mergedMaxLength; + + /** + * @ORM\Column(length=20) + * @Assert\Length(min=1, max=10) + */ + public $alreadyMappedMaxLength; + + /** + * @ORM\Column(unique=true) + */ + public $unique; + + /** + * @ORM\Column(unique=true) + */ + public $alreadyMappedUnique; +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Form/ChoiceList/DoctrineChoiceLoaderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Form/ChoiceList/DoctrineChoiceLoaderTest.php index de6f3a3aa970a..5a5fba5afaf57 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Form/ChoiceList/DoctrineChoiceLoaderTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Form/ChoiceList/DoctrineChoiceLoaderTest.php @@ -18,6 +18,7 @@ use Symfony\Bridge\Doctrine\Form\ChoiceList\DoctrineChoiceLoader; use Symfony\Bridge\Doctrine\Form\ChoiceList\EntityLoaderInterface; use Symfony\Bridge\Doctrine\Form\ChoiceList\IdReader; +use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdEntity; use Symfony\Component\Form\ChoiceList\ArrayChoiceList; use Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface; @@ -80,6 +81,11 @@ protected function setUp() $this->idReader = $this->getMockBuilder('Symfony\Bridge\Doctrine\Form\ChoiceList\IdReader') ->disableOriginalConstructor() ->getMock(); + $this->idReader->expects($this->any()) + ->method('isSingleId') + ->willReturn(true) + ; + $this->objectLoader = $this->getMockBuilder('Symfony\Bridge\Doctrine\Form\ChoiceList\EntityLoaderInterface')->getMock(); $this->obj1 = (object) ['name' => 'A']; $this->obj2 = (object) ['name' => 'B']; @@ -150,7 +156,7 @@ public function testLoadValuesForChoices() $loader = new DoctrineChoiceLoader( $this->om, $this->class, - $this->idReader + null ); $choices = [$this->obj1, $this->obj2, $this->obj3]; @@ -188,10 +194,6 @@ public function testLoadValuesForChoicesDoesNotLoadIfSingleIntId() $this->idReader ); - $this->idReader->expects($this->any()) - ->method('isSingleId') - ->willReturn(true); - $this->repository->expects($this->never()) ->method('findAll'); @@ -214,10 +216,6 @@ public function testLoadValuesForChoicesLoadsIfSingleIntIdAndValueGiven() $choices = [$this->obj1, $this->obj2, $this->obj3]; $value = function (\stdClass $object) { return $object->name; }; - $this->idReader->expects($this->any()) - ->method('isSingleId') - ->willReturn(true); - $this->repository->expects($this->once()) ->method('findAll') ->willReturn($choices); @@ -238,10 +236,6 @@ public function testLoadValuesForChoicesDoesNotLoadIfValueIsIdReader() $value = [$this->idReader, 'getIdValue']; - $this->idReader->expects($this->any()) - ->method('isSingleId') - ->willReturn(true); - $this->repository->expects($this->never()) ->method('findAll'); @@ -302,10 +296,6 @@ public function testLoadChoicesForValuesLoadsOnlyChoicesIfSingleIntId() $choices = [$this->obj2, $this->obj3]; - $this->idReader->expects($this->any()) - ->method('isSingleId') - ->willReturn(true); - $this->idReader->expects($this->any()) ->method('getIdField') ->willReturn('idField'); @@ -342,10 +332,6 @@ public function testLoadChoicesForValuesLoadsAllIfSingleIntIdAndValueGiven() $choices = [$this->obj1, $this->obj2, $this->obj3]; $value = function (\stdClass $object) { return $object->name; }; - $this->idReader->expects($this->any()) - ->method('isSingleId') - ->willReturn(true); - $this->repository->expects($this->once()) ->method('findAll') ->willReturn($choices); @@ -368,10 +354,6 @@ public function testLoadChoicesForValuesLoadsOnlyChoicesIfValueIsIdReader() $choices = [$this->obj2, $this->obj3]; $value = [$this->idReader, 'getIdValue']; - $this->idReader->expects($this->any()) - ->method('isSingleId') - ->willReturn(true); - $this->idReader->expects($this->any()) ->method('getIdField') ->willReturn('idField'); @@ -393,4 +375,88 @@ public function testLoadChoicesForValuesLoadsOnlyChoicesIfValueIsIdReader() $this->assertSame([$this->obj2], $loader->loadChoicesForValues(['2'], $value)); } + + /** + * @group legacy + * + * @expectedDeprecation Not explicitly passing an instance of "Symfony\Bridge\Doctrine\Form\ChoiceList\IdReader" to "Symfony\Bridge\Doctrine\Form\ChoiceList\DoctrineChoiceLoader" when it can optimize single id entity "%s" has been deprecated in 4.3 and will not apply any optimization in 5.0. + */ + public function testLoaderWithoutIdReaderCanBeOptimized() + { + $obj1 = new SingleIntIdEntity('1', 'one'); + $obj2 = new SingleIntIdEntity('2', 'two'); + + $metadata = $this->createMock(ClassMetadata::class); + $metadata->expects($this->once()) + ->method('getIdentifierFieldNames') + ->willReturn(['idField']) + ; + $metadata->expects($this->any()) + ->method('getIdentifierValues') + ->willReturnCallback(function ($obj) use ($obj1, $obj2) { + if ($obj === $obj1) { + return ['idField' => '1']; + } + if ($obj === $obj2) { + return ['idField' => '2']; + } + + return null; + }) + ; + + $this->om = $this->createMock(ObjectManager::class); + $this->om->expects($this->once()) + ->method('getClassMetadata') + ->with(SingleIntIdEntity::class) + ->willReturn($metadata) + ; + $this->om->expects($this->any()) + ->method('contains') + ->with($this->isInstanceOf(SingleIntIdEntity::class)) + ->willReturn(true) + ; + + $loader = new DoctrineChoiceLoader( + $this->om, + SingleIntIdEntity::class, + null, + $this->objectLoader + ); + + $choices = [$obj1, $obj2]; + + $this->repository->expects($this->never()) + ->method('findAll'); + + $this->objectLoader->expects($this->once()) + ->method('getEntitiesByIds') + ->with('idField', ['1']) + ->willReturn($choices); + + $this->assertSame([$obj1], $loader->loadChoicesForValues(['1'])); + } + + /** + * @group legacy + * + * @deprecationMessage Passing an instance of "Symfony\Bridge\Doctrine\Form\ChoiceList\IdReader" to "Symfony\Bridge\Doctrine\Form\ChoiceList\DoctrineChoiceLoader" with an entity class "stdClass" that has a composite id is deprecated since Symfony 4.3 and will throw an exception in 5.0. + */ + public function testPassingIdReaderWithoutSingleIdEntity() + { + $idReader = $this->createMock(IdReader::class); + $idReader->expects($this->once()) + ->method('isSingleId') + ->willReturn(false) + ; + + $loader = new DoctrineChoiceLoader( + $this->om, + $this->class, + $idReader, + $this->objectLoader + ); + + $this->assertInstanceOf(DoctrineChoiceLoader::class, $loader); + } } diff --git a/src/Symfony/Bridge/Doctrine/Tests/Form/EventListener/MergeDoctrineCollectionListenerTest.php b/src/Symfony/Bridge/Doctrine/Tests/Form/EventListener/MergeDoctrineCollectionListenerTest.php index e9994b5c9d240..757cdc3934c99 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Form/EventListener/MergeDoctrineCollectionListenerTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Form/EventListener/MergeDoctrineCollectionListenerTest.php @@ -63,7 +63,7 @@ public function testOnSubmitDoNothing() $submittedData = ['test']; $event = new FormEvent($this->getForm(), $submittedData); - $this->dispatcher->dispatch(FormEvents::SUBMIT, $event); + $this->dispatcher->dispatch($event, FormEvents::SUBMIT); $this->assertTrue($this->collection->contains('test')); $this->assertSame(1, $this->collection->count()); @@ -74,7 +74,7 @@ public function testOnSubmitNullClearCollection() $submittedData = []; $event = new FormEvent($this->getForm(), $submittedData); - $this->dispatcher->dispatch(FormEvents::SUBMIT, $event); + $this->dispatcher->dispatch($event, FormEvents::SUBMIT); $this->assertTrue($this->collection->isEmpty()); } diff --git a/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypeTest.php b/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypeTest.php index 1cb59c38436ef..3fe86b19a0149 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypeTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypeTest.php @@ -1368,6 +1368,180 @@ public function testDefaultTranslationDomain() $this->assertNull($view['child']->vars['translation_domain']); } + public function testPassLabelTranslationParametersToView() + { + $view = $this->factory->create(static::TESTED_TYPE, null, [ + 'label_translation_parameters' => ['%param%' => 'value'], + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->createView(); + + $this->assertSame(['%param%' => 'value'], $view->vars['label_translation_parameters']); + } + + public function testPassHelpTranslationParametersToView() + { + $view = $this->factory->create(static::TESTED_TYPE, null, [ + 'help_translation_parameters' => ['%param%' => 'value'], + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->createView(); + + $this->assertSame(['%param%' => 'value'], $view->vars['help_translation_parameters']); + } + + public function testPassAttrTranslationParametersToView() + { + $view = $this->factory->create(static::TESTED_TYPE, null, [ + 'attr_translation_parameters' => ['%param%' => 'value'], + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->createView(); + + $this->assertSame(['%param%' => 'value'], $view->vars['attr_translation_parameters']); + } + + public function testInheritLabelTranslationParametersFromParent() + { + $view = $this->factory + ->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE, null, [ + 'label_translation_parameters' => ['%param%' => 'value'], + ]) + ->add('child', static::TESTED_TYPE, [ + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->getForm() + ->createView(); + + $this->assertEquals(['%param%' => 'value'], $view['child']->vars['label_translation_parameters']); + } + + public function testInheritHelpTranslationParametersFromParent() + { + $view = $this->factory + ->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE, null, [ + 'help_translation_parameters' => ['%param%' => 'value'], + ]) + ->add('child', static::TESTED_TYPE, [ + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->getForm() + ->createView(); + + $this->assertEquals(['%param%' => 'value'], $view['child']->vars['help_translation_parameters']); + } + + public function testInheritAttrTranslationParametersFromParent() + { + $view = $this->factory + ->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE, null, [ + 'attr_translation_parameters' => ['%param%' => 'value'], + ]) + ->add('child', static::TESTED_TYPE, [ + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->getForm() + ->createView(); + + $this->assertEquals(['%param%' => 'value'], $view['child']->vars['attr_translation_parameters']); + } + + public function testPreferOwnLabelTranslationParameters() + { + $view = $this->factory + ->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE, null, [ + 'label_translation_parameters' => ['%parent_param%' => 'parent_value', '%override_param%' => 'parent_override_value'], + ]) + ->add('child', static::TESTED_TYPE, [ + 'label_translation_parameters' => ['%override_param%' => 'child_value'], + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->getForm() + ->createView(); + + $this->assertEquals(['%parent_param%' => 'parent_value', '%override_param%' => 'child_value'], $view['child']->vars['label_translation_parameters']); + } + + public function testPreferOwnHelpTranslationParameters() + { + $view = $this->factory + ->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE, null, [ + 'help_translation_parameters' => ['%parent_param%' => 'parent_value', '%override_param%' => 'parent_override_value'], + ]) + ->add('child', static::TESTED_TYPE, [ + 'help_translation_parameters' => ['%override_param%' => 'child_value'], + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->getForm() + ->createView(); + + $this->assertEquals(['%parent_param%' => 'parent_value', '%override_param%' => 'child_value'], $view['child']->vars['help_translation_parameters']); + } + + public function testPreferOwnAttrTranslationParameters() + { + $view = $this->factory + ->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE, null, [ + 'attr_translation_parameters' => ['%parent_param%' => 'parent_value', '%override_param%' => 'parent_override_value'], + ]) + ->add('child', static::TESTED_TYPE, [ + 'attr_translation_parameters' => ['%override_param%' => 'child_value'], + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->getForm() + ->createView(); + + $this->assertEquals(['%parent_param%' => 'parent_value', '%override_param%' => 'child_value'], $view['child']->vars['attr_translation_parameters']); + } + + public function testDefaultLabelTranslationParameters() + { + $view = $this->factory->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE) + ->add('child', static::TESTED_TYPE, [ + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->getForm() + ->createView(); + + $this->assertEquals([], $view['child']->vars['label_translation_parameters']); + } + + public function testDefaultHelpTranslationParameters() + { + $view = $this->factory->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE) + ->add('child', static::TESTED_TYPE, [ + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->getForm() + ->createView(); + + $this->assertEquals([], $view['child']->vars['help_translation_parameters']); + } + + public function testDefaultAttrTranslationParameters() + { + $view = $this->factory->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE) + ->add('child', static::TESTED_TYPE, [ + 'em' => 'default', + 'class' => self::SINGLE_IDENT_CLASS, + ]) + ->getForm() + ->createView(); + + $this->assertEquals([], $view['child']->vars['attr_translation_parameters']); + } + public function testPassLabelToView() { $view = $this->factory->createNamed('__test___field', static::TESTED_TYPE, null, [ diff --git a/src/Symfony/Bridge/Doctrine/Tests/Resources/orm/BaseUser.orm.xml b/src/Symfony/Bridge/Doctrine/Tests/Resources/orm/BaseUser.orm.xml new file mode 100644 index 0000000000000..da4a536fd94e6 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Resources/orm/BaseUser.orm.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/Symfony/Bridge/Doctrine/Tests/Resources/validator/BaseUser.xml b/src/Symfony/Bridge/Doctrine/Tests/Resources/validator/BaseUser.xml new file mode 100644 index 0000000000000..bf64b92ca484d --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Resources/validator/BaseUser.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php index 7188a3abc1a75..eaa86b39f8f5f 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php @@ -105,7 +105,7 @@ public function testRefreshUserRequiresId() $user1 = new User(null, null, 'user1'); $provider = new EntityUserProvider($this->getManager($em), 'Symfony\Bridge\Doctrine\Tests\Fixtures\User', 'name'); - $this->{method_exists($this, $_ = 'expectException') ? $_ : 'setExpectedException'}( + $this->expectException( 'InvalidArgumentException', 'You cannot refresh a user from the EntityUserProvider that does not contain an identifier. The user object has to be serialized with its own identifier mapped by Doctrine' ); @@ -125,7 +125,7 @@ public function testRefreshInvalidUser() $provider = new EntityUserProvider($this->getManager($em), 'Symfony\Bridge\Doctrine\Tests\Fixtures\User', 'name'); $user2 = new User(1, 2, 'user2'); - $this->{method_exists($this, $_ = 'expectException') ? $_ : 'setExpectedException'}( + $this->expectException( 'Symfony\Component\Security\Core\Exception\UsernameNotFoundException', 'User with id {"id1":1,"id2":2} not found' ); diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php new file mode 100644 index 0000000000000..cde956eed3493 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Validator; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper; +use Symfony\Bridge\Doctrine\Tests\Fixtures\BaseUser; +use Symfony\Bridge\Doctrine\Tests\Fixtures\DoctrineLoaderEntity; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Bridge\Doctrine\Validator\DoctrineLoader; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Tests\Fixtures\Entity; +use Symfony\Component\Validator\Validation; +use Symfony\Component\Validator\ValidatorBuilder; + +/** + * @author Kévin Dunglas + */ +class DoctrineLoaderTest extends TestCase +{ + public function testLoadClassMetadata() + { + if (!method_exists(ValidatorBuilder::class, 'addLoader')) { + $this->markTestSkipped('Auto-mapping requires symfony/validation 4.2+'); + } + + $validator = Validation::createValidatorBuilder() + ->enableAnnotationMapping() + ->addLoader(new DoctrineLoader(DoctrineTestHelper::createTestEntityManager())) + ->getValidator() + ; + + $classMetadata = $validator->getMetadataFor(new DoctrineLoaderEntity()); + + $classConstraints = $classMetadata->getConstraints(); + $this->assertCount(2, $classConstraints); + $this->assertInstanceOf(UniqueEntity::class, $classConstraints[0]); + $this->assertInstanceOf(UniqueEntity::class, $classConstraints[1]); + $this->assertSame(['alreadyMappedUnique'], $classConstraints[0]->fields); + $this->assertSame('unique', $classConstraints[1]->fields); + + $maxLengthMetadata = $classMetadata->getPropertyMetadata('maxLength'); + $this->assertCount(1, $maxLengthMetadata); + $maxLengthConstraints = $maxLengthMetadata[0]->getConstraints(); + $this->assertCount(1, $maxLengthConstraints); + $this->assertInstanceOf(Length::class, $maxLengthConstraints[0]); + $this->assertSame(20, $maxLengthConstraints[0]->max); + + $mergedMaxLengthMetadata = $classMetadata->getPropertyMetadata('mergedMaxLength'); + $this->assertCount(1, $mergedMaxLengthMetadata); + $mergedMaxLengthConstraints = $mergedMaxLengthMetadata[0]->getConstraints(); + $this->assertCount(1, $mergedMaxLengthConstraints); + $this->assertInstanceOf(Length::class, $mergedMaxLengthConstraints[0]); + $this->assertSame(20, $mergedMaxLengthConstraints[0]->max); + $this->assertSame(5, $mergedMaxLengthConstraints[0]->min); + + $alreadyMappedMaxLengthMetadata = $classMetadata->getPropertyMetadata('alreadyMappedMaxLength'); + $this->assertCount(1, $alreadyMappedMaxLengthMetadata); + $alreadyMappedMaxLengthConstraints = $alreadyMappedMaxLengthMetadata[0]->getConstraints(); + $this->assertCount(1, $alreadyMappedMaxLengthConstraints); + $this->assertInstanceOf(Length::class, $alreadyMappedMaxLengthConstraints[0]); + $this->assertSame(10, $alreadyMappedMaxLengthConstraints[0]->max); + $this->assertSame(1, $alreadyMappedMaxLengthConstraints[0]->min); + } + + public function testFieldMappingsConfiguration() + { + if (!method_exists(ValidatorBuilder::class, 'addLoader')) { + $this->markTestSkipped('Auto-mapping requires symfony/validation 4.2+'); + } + + $validator = Validation::createValidatorBuilder() + ->enableAnnotationMapping() + ->addXmlMappings([__DIR__.'/../Resources/validator/BaseUser.xml']) + ->addLoader( + new DoctrineLoader( + DoctrineTestHelper::createTestEntityManager( + DoctrineTestHelper::createTestConfigurationWithXmlLoader() + ), '{}' + ) + ) + ->getValidator(); + + $classMetadata = $validator->getMetadataFor(new BaseUser(1, 'DemoUser')); + + $constraints = $classMetadata->getConstraints(); + $this->assertCount(0, $constraints); + } + + /** + * @dataProvider regexpProvider + */ + public function testClassValidator(bool $expected, string $classValidatorRegexp = null) + { + $doctrineLoader = new DoctrineLoader(DoctrineTestHelper::createTestEntityManager(), $classValidatorRegexp); + + $classMetadata = new ClassMetadata(DoctrineLoaderEntity::class); + $this->assertSame($expected, $doctrineLoader->loadClassMetadata($classMetadata)); + } + + public function regexpProvider() + { + return [ + [true, null], + [true, '{^'.preg_quote(DoctrineLoaderEntity::class).'$|^'.preg_quote(Entity::class).'$}'], + [false, '{^'.preg_quote(Entity::class).'$}'], + ]; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php b/src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php new file mode 100644 index 0000000000000..9ae31671bd933 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Validator; + +use Doctrine\Common\Persistence\Mapping\MappingException; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\Mapping\MappingException as OrmMappingException; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\LoaderInterface; + +/** + * Guesses and loads the appropriate constraints using Doctrine's metadata. + * + * @author Kévin Dunglas + */ +final class DoctrineLoader implements LoaderInterface +{ + private $entityManager; + private $classValidatorRegexp; + + public function __construct(EntityManagerInterface $entityManager, string $classValidatorRegexp = null) + { + $this->entityManager = $entityManager; + $this->classValidatorRegexp = $classValidatorRegexp; + } + + /** + * {@inheritdoc} + */ + public function loadClassMetadata(ClassMetadata $metadata): bool + { + $className = $metadata->getClassName(); + if (null !== $this->classValidatorRegexp && !preg_match($this->classValidatorRegexp, $className)) { + return false; + } + + try { + $doctrineMetadata = $this->entityManager->getClassMetadata($className); + } catch (MappingException | OrmMappingException $exception) { + return false; + } + + if (!$doctrineMetadata instanceof ClassMetadataInfo) { + return false; + } + + /* Available keys: + - type + - scale + - length + - unique + - nullable + - precision + */ + $existingUniqueFields = $this->getExistingUniqueFields($metadata); + + // Type and nullable aren't handled here, use the PropertyInfo Loader instead. + foreach ($doctrineMetadata->fieldMappings as $mapping) { + if (true === ($mapping['unique'] ?? false) && !isset($existingUniqueFields[$mapping['fieldName']])) { + $metadata->addConstraint(new UniqueEntity(['fields' => $mapping['fieldName']])); + } + + if (null === $mapping['length']) { + continue; + } + + $constraint = $this->getLengthConstraint($metadata, $mapping['fieldName']); + if (null === $constraint) { + $metadata->addPropertyConstraint($mapping['fieldName'], new Length(['max' => $mapping['length']])); + } elseif (null === $constraint->max) { + // If a Length constraint exists and no max length has been explicitly defined, set it + $constraint->max = $mapping['length']; + } + } + + return true; + } + + private function getLengthConstraint(ClassMetadata $metadata, string $fieldName): ?Length + { + foreach ($metadata->getPropertyMetadata($fieldName) as $propertyMetadata) { + foreach ($propertyMetadata->getConstraints() as $constraint) { + if ($constraint instanceof Length) { + return $constraint; + } + } + } + + return null; + } + + private function getExistingUniqueFields(ClassMetadata $metadata): array + { + $fields = []; + foreach ($metadata->getConstraints() as $constraint) { + if (!$constraint instanceof UniqueEntity) { + continue; + } + + if (\is_string($constraint->fields)) { + $fields[$constraint->fields] = true; + } elseif (\is_array($constraint->fields) && 1 === \count($constraint->fields)) { + $fields[$constraint->fields[0]] = true; + } + } + + return $fields; + } +} diff --git a/src/Symfony/Bridge/Doctrine/composer.json b/src/Symfony/Bridge/Doctrine/composer.json index db7c8ce7a341e..819af8522046f 100644 --- a/src/Symfony/Bridge/Doctrine/composer.json +++ b/src/Symfony/Bridge/Doctrine/composer.json @@ -17,7 +17,6 @@ ], "require": { "php": "^7.1.3", - "doctrine/collections": "~1.0", "doctrine/event-manager": "~1.0", "doctrine/persistence": "~1.0", "symfony/contracts": "^1.0", @@ -27,7 +26,7 @@ "require-dev": { "symfony/stopwatch": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/form": "~3.4|~4.0", + "symfony/form": "~4.3", "symfony/http-kernel": "~3.4|~4.0", "symfony/messenger": "~4.2", "symfony/property-access": "~3.4|~4.0", @@ -39,6 +38,7 @@ "symfony/translation": "~3.4|~4.0", "doctrine/annotations": "~1.0", "doctrine/cache": "~1.6", + "doctrine/collections": "~1.0", "doctrine/data-fixtures": "1.0.*", "doctrine/dbal": "~2.4", "doctrine/orm": "^2.4.5", @@ -47,6 +47,7 @@ "conflict": { "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0", "symfony/dependency-injection": "<3.4", + "symfony/form": "<4.3", "symfony/messenger": "<4.2" }, "suggest": { @@ -66,7 +67,7 @@ "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } } } diff --git a/src/Symfony/Bridge/Monolog/CHANGELOG.md b/src/Symfony/Bridge/Monolog/CHANGELOG.md index 2cb6c3f39d897..8b519c9f31104 100644 --- a/src/Symfony/Bridge/Monolog/CHANGELOG.md +++ b/src/Symfony/Bridge/Monolog/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +4.3.0 +----- + + * added `ConsoleCommandProcessor`: monolog processor that adds command name and arguments + * added `RouteProcessor`: monolog processor that adds route name, controller::action and route params + 4.2.0 ----- diff --git a/src/Symfony/Bridge/Monolog/Handler/ChromePhpHandler.php b/src/Symfony/Bridge/Monolog/Handler/ChromePhpHandler.php index c27b0803e200e..4f98d58b1ffbc 100644 --- a/src/Symfony/Bridge/Monolog/Handler/ChromePhpHandler.php +++ b/src/Symfony/Bridge/Monolog/Handler/ChromePhpHandler.php @@ -19,6 +19,8 @@ * ChromePhpHandler. * * @author Christophe Coevoet + * + * @final since Symfony 4.3 */ class ChromePhpHandler extends BaseChromePhpHandler { diff --git a/src/Symfony/Bridge/Monolog/Handler/ConsoleHandler.php b/src/Symfony/Bridge/Monolog/Handler/ConsoleHandler.php index 997ecc107cce5..1ec91e43f29a2 100644 --- a/src/Symfony/Bridge/Monolog/Handler/ConsoleHandler.php +++ b/src/Symfony/Bridge/Monolog/Handler/ConsoleHandler.php @@ -50,6 +50,7 @@ class ConsoleHandler extends AbstractProcessingHandler implements EventSubscribe OutputInterface::VERBOSITY_VERY_VERBOSE => Logger::INFO, OutputInterface::VERBOSITY_DEBUG => Logger::DEBUG, ]; + private $consoleFormaterOptions; /** * @param OutputInterface|null $output The console output to use (the handler remains disabled when passing null @@ -58,7 +59,7 @@ class ConsoleHandler extends AbstractProcessingHandler implements EventSubscribe * @param array $verbosityLevelMap Array that maps the OutputInterface verbosity to a minimum logging * level (leave empty to use the default mapping) */ - public function __construct(OutputInterface $output = null, bool $bubble = true, array $verbosityLevelMap = []) + public function __construct(OutputInterface $output = null, bool $bubble = true, array $verbosityLevelMap = [], array $consoleFormaterOptions = []) { parent::__construct(Logger::DEBUG, $bubble); $this->output = $output; @@ -66,6 +67,8 @@ public function __construct(OutputInterface $output = null, bool $bubble = true, if ($verbosityLevelMap) { $this->verbosityLevelMap = $verbosityLevelMap; } + + $this->consoleFormaterOptions = $consoleFormaterOptions; } /** @@ -155,13 +158,13 @@ protected function getDefaultFormatter() return new LineFormatter(); } if (!$this->output) { - return new ConsoleFormatter(); + return new ConsoleFormatter($this->consoleFormaterOptions); } - return new ConsoleFormatter([ + return new ConsoleFormatter(array_replace([ 'colors' => $this->output->isDecorated(), 'multiline' => OutputInterface::VERBOSITY_DEBUG <= $this->output->getVerbosity(), - ]); + ], $this->consoleFormaterOptions)); } /** diff --git a/src/Symfony/Bridge/Monolog/Handler/FirePHPHandler.php b/src/Symfony/Bridge/Monolog/Handler/FirePHPHandler.php index 9c3ec5f98144c..b235fc101ea73 100644 --- a/src/Symfony/Bridge/Monolog/Handler/FirePHPHandler.php +++ b/src/Symfony/Bridge/Monolog/Handler/FirePHPHandler.php @@ -19,6 +19,8 @@ * FirePHPHandler. * * @author Jordi Boggiano + * + * @final since Symfony 4.3 */ class FirePHPHandler extends BaseFirePHPHandler { diff --git a/src/Symfony/Bridge/Monolog/Handler/SwiftMailerHandler.php b/src/Symfony/Bridge/Monolog/Handler/SwiftMailerHandler.php index c912614a2abb9..93f2f72e6457a 100644 --- a/src/Symfony/Bridge/Monolog/Handler/SwiftMailerHandler.php +++ b/src/Symfony/Bridge/Monolog/Handler/SwiftMailerHandler.php @@ -19,6 +19,8 @@ * Extended SwiftMailerHandler that flushes mail queue if necessary. * * @author Philipp Kräutli + * + * @final since Symfony 4.3 */ class SwiftMailerHandler extends BaseSwiftMailerHandler { diff --git a/src/Symfony/Bridge/Monolog/Logger.php b/src/Symfony/Bridge/Monolog/Logger.php index fb668be62b4d5..5141ac955f44d 100644 --- a/src/Symfony/Bridge/Monolog/Logger.php +++ b/src/Symfony/Bridge/Monolog/Logger.php @@ -80,6 +80,21 @@ public function reset() } } + public function removeDebugLogger() + { + foreach ($this->processors as $k => $processor) { + if ($processor instanceof DebugLoggerInterface) { + unset($this->processors[$k]); + } + } + + foreach ($this->handlers as $k => $handler) { + if ($handler instanceof DebugLoggerInterface) { + unset($this->handlers[$k]); + } + } + } + /** * Returns a DebugLoggerInterface instance if one is registered with this logger. * diff --git a/src/Symfony/Bridge/Monolog/Processor/ConsoleCommandProcessor.php b/src/Symfony/Bridge/Monolog/Processor/ConsoleCommandProcessor.php new file mode 100644 index 0000000000000..2891f4f2f4916 --- /dev/null +++ b/src/Symfony/Bridge/Monolog/Processor/ConsoleCommandProcessor.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Monolog\Processor; + +use Symfony\Component\Console\ConsoleEvents; +use Symfony\Component\Console\Event\ConsoleEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Contracts\Service\ResetInterface; + +/** + * Adds the current console command information to the log entry. + * + * @author Piotr Stankowski + */ +class ConsoleCommandProcessor implements EventSubscriberInterface, ResetInterface +{ + private $commandData; + private $includeArguments; + private $includeOptions; + + public function __construct(bool $includeArguments = true, bool $includeOptions = false) + { + $this->includeArguments = $includeArguments; + $this->includeOptions = $includeOptions; + } + + public function __invoke(array $records) + { + if (null !== $this->commandData && !isset($records['extra']['command'])) { + $records['extra']['command'] = $this->commandData; + } + + return $records; + } + + public function reset() + { + $this->commandData = null; + } + + public function addCommandData(ConsoleEvent $event) + { + $this->commandData = [ + 'name' => $event->getCommand()->getName(), + ]; + if ($this->includeArguments) { + $this->commandData['arguments'] = $event->getInput()->getArguments(); + } + if ($this->includeOptions) { + $this->commandData['options'] = $event->getInput()->getOptions(); + } + } + + public static function getSubscribedEvents() + { + return [ + ConsoleEvents::COMMAND => ['addCommandData', 1], + ]; + } +} diff --git a/src/Symfony/Bridge/Monolog/Processor/RouteProcessor.php b/src/Symfony/Bridge/Monolog/Processor/RouteProcessor.php new file mode 100644 index 0000000000000..0160754ad9575 --- /dev/null +++ b/src/Symfony/Bridge/Monolog/Processor/RouteProcessor.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Monolog\Processor; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\FinishRequestEvent; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Contracts\Service\ResetInterface; + +/** + * Adds the current route information to the log entry. + * + * @author Piotr Stankowski + */ +class RouteProcessor implements EventSubscriberInterface, ResetInterface +{ + private $routeData; + private $includeParams; + + public function __construct(bool $includeParams = true) + { + $this->includeParams = $includeParams; + $this->reset(); + } + + public function __invoke(array $records) + { + if ($this->routeData && !isset($records['extra']['requests'])) { + $records['extra']['requests'] = array_values($this->routeData); + } + + return $records; + } + + public function reset() + { + $this->routeData = []; + } + + public function addRouteData(GetResponseEvent $event) + { + if ($event->isMasterRequest()) { + $this->reset(); + } + + $request = $event->getRequest(); + if (!$request->attributes->has('_controller')) { + return; + } + + $currentRequestData = [ + 'controller' => $request->attributes->get('_controller'), + 'route' => $request->attributes->get('_route'), + ]; + + if ($this->includeParams) { + $currentRequestData['route_params'] = $request->attributes->get('_route_params'); + } + + $this->routeData[spl_object_id($request)] = $currentRequestData; + } + + public function removeRouteData(FinishRequestEvent $event) + { + $requestId = spl_object_id($event->getRequest()); + unset($this->routeData[$requestId]); + } + + public static function getSubscribedEvents() + { + return [ + KernelEvents::REQUEST => ['addRouteData', 1], + KernelEvents::FINISH_REQUEST => ['removeRouteData', 1], + ]; + } +} diff --git a/src/Symfony/Bridge/Monolog/Processor/TokenProcessor.php b/src/Symfony/Bridge/Monolog/Processor/TokenProcessor.php index 7bf03a036a257..7613d01361962 100644 --- a/src/Symfony/Bridge/Monolog/Processor/TokenProcessor.php +++ b/src/Symfony/Bridge/Monolog/Processor/TokenProcessor.php @@ -31,10 +31,16 @@ public function __invoke(array $records) { $records['extra']['token'] = null; if (null !== $token = $this->tokenStorage->getToken()) { + if (method_exists($token, 'getRoleNames')) { + $roles = $token->getRoleNames(); + } else { + $roles = array_map(function ($role) { return $role->getRole(); }, $token->getRoles(false)); + } + $records['extra']['token'] = [ 'username' => $token->getUsername(), 'authenticated' => $token->isAuthenticated(), - 'roles' => array_map(function ($role) { return $role->getRole(); }, $token->getRoles()), + 'roles' => $roles, ]; } diff --git a/src/Symfony/Bridge/Monolog/Processor/WebProcessor.php b/src/Symfony/Bridge/Monolog/Processor/WebProcessor.php index 8bf8cec3dfeb3..71bf71a816327 100644 --- a/src/Symfony/Bridge/Monolog/Processor/WebProcessor.php +++ b/src/Symfony/Bridge/Monolog/Processor/WebProcessor.php @@ -20,6 +20,8 @@ * WebProcessor override to read from the HttpFoundation's Request. * * @author Jordi Boggiano + * + * @final since Symfony 4.3 */ class WebProcessor extends BaseWebProcessor implements EventSubscriberInterface { diff --git a/src/Symfony/Bridge/Monolog/Tests/Handler/ConsoleHandlerTest.php b/src/Symfony/Bridge/Monolog/Tests/Handler/ConsoleHandlerTest.php index 00106c54e162d..192238c839340 100644 --- a/src/Symfony/Bridge/Monolog/Tests/Handler/ConsoleHandlerTest.php +++ b/src/Symfony/Bridge/Monolog/Tests/Handler/ConsoleHandlerTest.php @@ -196,12 +196,12 @@ public function testLogsFromListeners() }); $event = new ConsoleCommandEvent(new Command('foo'), $this->getMockBuilder('Symfony\Component\Console\Input\InputInterface')->getMock(), $output); - $dispatcher->dispatch(ConsoleEvents::COMMAND, $event); + $dispatcher->dispatch($event, ConsoleEvents::COMMAND); $this->assertContains('Before command message.', $out = $output->fetch()); $this->assertContains('After command message.', $out); $event = new ConsoleTerminateEvent(new Command('foo'), $this->getMockBuilder('Symfony\Component\Console\Input\InputInterface')->getMock(), $output, 0); - $dispatcher->dispatch(ConsoleEvents::TERMINATE, $event); + $dispatcher->dispatch($event, ConsoleEvents::TERMINATE); $this->assertContains('Before terminate message.', $out = $output->fetch()); $this->assertContains('After terminate message.', $out); } diff --git a/src/Symfony/Bridge/Monolog/Tests/Processor/ConsoleCommandProcessorTest.php b/src/Symfony/Bridge/Monolog/Tests/Processor/ConsoleCommandProcessorTest.php new file mode 100644 index 0000000000000..4c9774b4a4385 --- /dev/null +++ b/src/Symfony/Bridge/Monolog/Tests/Processor/ConsoleCommandProcessorTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Monolog\Tests\Processor; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Monolog\Processor\ConsoleCommandProcessor; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Event\ConsoleEvent; +use Symfony\Component\Console\Input\InputInterface; + +class ConsoleCommandProcessorTest extends TestCase +{ + private const TEST_ARGUMENTS = ['test' => 'argument']; + private const TEST_OPTIONS = ['test' => 'option']; + private const TEST_NAME = 'some:test'; + + public function testProcessor() + { + $processor = new ConsoleCommandProcessor(); + $processor->addCommandData($this->getConsoleEvent()); + + $record = $processor(['extra' => []]); + + $this->assertArrayHasKey('command', $record['extra']); + $this->assertEquals( + ['name' => self::TEST_NAME, 'arguments' => self::TEST_ARGUMENTS], + $record['extra']['command'] + ); + } + + public function testProcessorWithOptions() + { + $processor = new ConsoleCommandProcessor(true, true); + $processor->addCommandData($this->getConsoleEvent()); + + $record = $processor(['extra' => []]); + + $this->assertArrayHasKey('command', $record['extra']); + $this->assertEquals( + ['name' => self::TEST_NAME, 'arguments' => self::TEST_ARGUMENTS, 'options' => self::TEST_OPTIONS], + $record['extra']['command'] + ); + } + + public function testProcessorDoesNothingWhenNotInConsole() + { + $processor = new ConsoleCommandProcessor(true, true); + + $record = $processor(['extra' => []]); + $this->assertEquals(['extra' => []], $record); + } + + private function getConsoleEvent(): ConsoleEvent + { + $input = $this->getMockBuilder(InputInterface::class)->getMock(); + $input->method('getArguments')->willReturn(self::TEST_ARGUMENTS); + $input->method('getOptions')->willReturn(self::TEST_OPTIONS); + $command = $this->getMockBuilder(Command::class)->disableOriginalConstructor()->getMock(); + $command->method('getName')->willReturn(self::TEST_NAME); + $consoleEvent = $this->getMockBuilder(ConsoleEvent::class)->disableOriginalConstructor()->getMock(); + $consoleEvent->method('getCommand')->willReturn($command); + $consoleEvent->method('getInput')->willReturn($input); + + return $consoleEvent; + } +} diff --git a/src/Symfony/Bridge/Monolog/Tests/Processor/RouteProcessorTest.php b/src/Symfony/Bridge/Monolog/Tests/Processor/RouteProcessorTest.php new file mode 100644 index 0000000000000..5534240ba278a --- /dev/null +++ b/src/Symfony/Bridge/Monolog/Tests/Processor/RouteProcessorTest.php @@ -0,0 +1,162 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Monolog\Tests\Processor; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Monolog\Processor\RouteProcessor; +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\FinishRequestEvent; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; + +class RouteProcessorTest extends TestCase +{ + private const TEST_CONTROLLER = 'App\Controller\SomeController::someMethod'; + private const TEST_ROUTE = 'someRouteName'; + private const TEST_PARAMS = ['param1' => 'value1']; + + public function testProcessor() + { + $request = $this->mockFilledRequest(); + $processor = new RouteProcessor(); + $processor->addRouteData($this->mockGetResponseEvent($request)); + + $record = $processor(['extra' => []]); + + $this->assertArrayHasKey('requests', $record['extra']); + $this->assertCount(1, $record['extra']['requests']); + $this->assertEquals( + ['controller' => self::TEST_CONTROLLER, 'route' => self::TEST_ROUTE, 'route_params' => self::TEST_PARAMS], + $record['extra']['requests'][0] + ); + } + + public function testProcessorWithoutParams() + { + $request = $this->mockFilledRequest(); + $processor = new RouteProcessor(false); + $processor->addRouteData($this->mockGetResponseEvent($request)); + + $record = $processor(['extra' => []]); + + $this->assertArrayHasKey('requests', $record['extra']); + $this->assertCount(1, $record['extra']['requests']); + $this->assertEquals( + ['controller' => self::TEST_CONTROLLER, 'route' => self::TEST_ROUTE], + $record['extra']['requests'][0] + ); + } + + public function testProcessorWithSubRequests() + { + $controllerFromSubRequest = 'OtherController::otherMethod'; + $mainRequest = $this->mockFilledRequest(); + $subRequest = $this->mockFilledRequest($controllerFromSubRequest); + + $processor = new RouteProcessor(false); + $processor->addRouteData($this->mockGetResponseEvent($mainRequest)); + $processor->addRouteData($this->mockGetResponseEvent($subRequest)); + + $record = $processor(['extra' => []]); + + $this->assertArrayHasKey('requests', $record['extra']); + $this->assertCount(2, $record['extra']['requests']); + $this->assertEquals( + ['controller' => self::TEST_CONTROLLER, 'route' => self::TEST_ROUTE], + $record['extra']['requests'][0] + ); + $this->assertEquals( + ['controller' => $controllerFromSubRequest, 'route' => self::TEST_ROUTE], + $record['extra']['requests'][1] + ); + } + + public function testFinishRequestRemovesRelatedEntry() + { + $mainRequest = $this->mockFilledRequest(); + $subRequest = $this->mockFilledRequest('OtherController::otherMethod'); + + $processor = new RouteProcessor(false); + $processor->addRouteData($this->mockGetResponseEvent($mainRequest)); + $processor->addRouteData($this->mockGetResponseEvent($subRequest)); + $processor->removeRouteData($this->mockFinishRequestEvent($subRequest)); + $record = $processor(['extra' => []]); + + $this->assertArrayHasKey('requests', $record['extra']); + $this->assertCount(1, $record['extra']['requests']); + $this->assertEquals( + ['controller' => self::TEST_CONTROLLER, 'route' => self::TEST_ROUTE], + $record['extra']['requests'][0] + ); + + $processor->removeRouteData($this->mockFinishRequestEvent($mainRequest)); + $record = $processor(['extra' => []]); + + $this->assertArrayNotHasKey('requests', $record['extra']); + } + + public function testProcessorWithEmptyRequest() + { + $request = $this->mockEmptyRequest(); + $processor = new RouteProcessor(); + $processor->addRouteData($this->mockGetResponseEvent($request)); + + $record = $processor(['extra' => []]); + $this->assertEquals(['extra' => []], $record); + } + + public function testProcessorDoesNothingWhenNoRequest() + { + $processor = new RouteProcessor(); + + $record = $processor(['extra' => []]); + $this->assertEquals(['extra' => []], $record); + } + + private function mockGetResponseEvent(Request $request): GetResponseEvent + { + $event = $this->getMockBuilder(GetResponseEvent::class)->disableOriginalConstructor()->getMock(); + $event->method('getRequest')->willReturn($request); + + return $event; + } + + private function mockFinishRequestEvent(Request $request): FinishRequestEvent + { + $event = $this->getMockBuilder(FinishRequestEvent::class)->disableOriginalConstructor()->getMock(); + $event->method('getRequest')->willReturn($request); + + return $event; + } + + private function mockEmptyRequest(): Request + { + return $this->mockRequest([]); + } + + private function mockFilledRequest(string $controller = self::TEST_CONTROLLER): Request + { + return $this->mockRequest([ + '_controller' => $controller, + '_route' => self::TEST_ROUTE, + '_route_params' => self::TEST_PARAMS, + ]); + } + + private function mockRequest(array $attributes): Request + { + $request = $this->getMockBuilder(Request::class)->disableOriginalConstructor()->getMock(); + $request->attributes = new ParameterBag($attributes); + + return $request; + } +} diff --git a/src/Symfony/Bridge/Monolog/Tests/Processor/TokenProcessorTest.php b/src/Symfony/Bridge/Monolog/Tests/Processor/TokenProcessorTest.php index 1b01348639b0d..ef3f6cc9f5c6a 100644 --- a/src/Symfony/Bridge/Monolog/Tests/Processor/TokenProcessorTest.php +++ b/src/Symfony/Bridge/Monolog/Tests/Processor/TokenProcessorTest.php @@ -36,7 +36,6 @@ public function testProcessor() $this->assertArrayHasKey('token', $record['extra']); $this->assertEquals($token->getUsername(), $record['extra']['token']['username']); $this->assertEquals($token->isAuthenticated(), $record['extra']['token']['authenticated']); - $roles = array_map(function ($role) { return $role->getRole(); }, $token->getRoles()); - $this->assertEquals($roles, $record['extra']['token']['roles']); + $this->assertEquals(['ROLE_USER'], $record['extra']['token']['roles']); } } diff --git a/src/Symfony/Bridge/Monolog/Tests/Processor/WebProcessorTest.php b/src/Symfony/Bridge/Monolog/Tests/Processor/WebProcessorTest.php index 6b61b5c986fc1..0f682e842cad5 100644 --- a/src/Symfony/Bridge/Monolog/Tests/Processor/WebProcessorTest.php +++ b/src/Symfony/Bridge/Monolog/Tests/Processor/WebProcessorTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\Monolog\Processor\WebProcessor; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\RequestEvent; class WebProcessorTest extends TestCase { @@ -87,7 +88,7 @@ private function createRequestEvent($additionalServerParameters = []): array $request->server->replace($server); $request->headers->replace($server); - $event = $this->getMockBuilder('Symfony\Component\HttpKernel\Event\GetResponseEvent') + $event = $this->getMockBuilder(RequestEvent::class) ->disableOriginalConstructor() ->getMock(); $event->expects($this->any()) diff --git a/src/Symfony/Bridge/Monolog/composer.json b/src/Symfony/Bridge/Monolog/composer.json index ce7fee470b247..81e7b15cd0e15 100644 --- a/src/Symfony/Bridge/Monolog/composer.json +++ b/src/Symfony/Bridge/Monolog/composer.json @@ -19,11 +19,10 @@ "php": "^7.1.3", "monolog/monolog": "~1.19", "symfony/contracts": "^1.0", - "symfony/http-kernel": "~3.4|~4.0" + "symfony/http-kernel": "^4.3" }, "require-dev": { "symfony/console": "~3.4|~4.0", - "symfony/event-dispatcher": "~3.4|~4.0", "symfony/security-core": "~3.4|~4.0", "symfony/var-dumper": "~3.4|~4.0" }, @@ -34,7 +33,6 @@ "suggest": { "symfony/http-kernel": "For using the debugging handlers together with the response life cycle of the HTTP kernel.", "symfony/console": "For the possibility to show log messages in console commands depending on verbosity settings.", - "symfony/event-dispatcher": "Needed when using log messages in console commands.", "symfony/var-dumper": "For using the debugging handlers like the console handler or the log server handler." }, "autoload": { @@ -46,7 +44,7 @@ "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } } } diff --git a/src/Symfony/Bridge/PhpUnit/CHANGELOG.md b/src/Symfony/Bridge/PhpUnit/CHANGELOG.md index 9c5ef00f1992a..6dfb949754968 100644 --- a/src/Symfony/Bridge/PhpUnit/CHANGELOG.md +++ b/src/Symfony/Bridge/PhpUnit/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +4.3.0 +----- + + * added `ClassExistsMock` + * bumped PHP version from 5.3.3 to 5.5.9 + 4.1.0 ----- diff --git a/src/Symfony/Bridge/PhpUnit/ClassExistsMock.php b/src/Symfony/Bridge/PhpUnit/ClassExistsMock.php new file mode 100644 index 0000000000000..e8ca4ac9402a8 --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/ClassExistsMock.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit; + +/** + * @author Roland Franssen + */ +class ClassExistsMock +{ + private static $classes = []; + + /** + * Configures the classes to be checked upon existence. + * + * @param array $classes Mocked class names as keys (case sensitive, without leading root namespace slash) and booleans as values + */ + public static function withMockedClasses(array $classes) + { + self::$classes = $classes; + } + + public static function class_exists($name, $autoload = true) + { + return (bool) (self::$classes[ltrim($name, '\\')] ?? \class_exists($name, $autoload)); + } + + public static function interface_exists($name, $autoload = true) + { + return (bool) (self::$classes[ltrim($name, '\\')] ?? \interface_exists($name, $autoload)); + } + + public static function trait_exists($name, $autoload = true) + { + return (bool) (self::$classes[ltrim($name, '\\')] ?? \trait_exists($name, $autoload)); + } + + public static function register($class) + { + $self = \get_called_class(); + + $mockedNs = [substr($class, 0, strrpos($class, '\\'))]; + if (0 < strpos($class, '\\Tests\\')) { + $ns = str_replace('\\Tests\\', '\\', $class); + $mockedNs[] = substr($ns, 0, strrpos($ns, '\\')); + } elseif (0 === strpos($class, 'Tests\\')) { + $mockedNs[] = substr($class, 6, strrpos($class, '\\') - 6); + } + foreach ($mockedNs as $ns) { + foreach (['class', 'interface', 'trait'] as $type) { + if (\function_exists($ns.'\\'.$type.'_exists')) { + continue; + } + eval(<< 0, + 'remaining selfCount' => 0, + 'legacyCount' => 0, + 'otherCount' => 0, + 'remaining directCount' => 0, + 'remaining indirectCount' => 0, + 'unsilenced' => [], + 'remaining self' => [], + 'legacy' => [], + 'other' => [], + 'remaining direct' => [], + 'remaining indirect' => [], + ]; private static $isRegistered = false; + private static $utilPrefix; /** * Registers and configures the deprecation handler. * - * The following reporting modes are supported: - * - use "weak" to hide the deprecation report but keep a global count; - * - use "weak_vendors" to fail only on deprecations triggered in your own code; - * - use "/some-regexp/" to stop the test suite whenever a deprecation - * message matches the given regular expression; - * - use a number to define the upper bound of allowed deprecations, - * making the test suite fail whenever more notices are triggered. + * The mode is a query string with options: + * - "disabled" to disable the deprecation handler + * - "verbose" to enable/disable displaying the deprecation report + * - "max" to configure the number of deprecations to allow before exiting with a non-zero + * status code; it's an array with keys "total", "self", "direct" and "indirect" + * + * The default mode is "max[total]=0&verbose=1". + * + * The mode can alternatively be "/some-regexp/" to stop the test suite whenever + * a deprecation message matches the given regular expression. * * @param int|string|false $mode The reporting mode, defaults to not allowing any deprecations */ @@ -43,261 +72,238 @@ public static function register($mode = 0) return; } - $UtilPrefix = class_exists('PHPUnit_Util_ErrorHandler') ? 'PHPUnit_Util_' : 'PHPUnit\Util\\'; + self::$utilPrefix = class_exists('PHPUnit_Util_ErrorHandler') ? 'PHPUnit_Util_' : 'PHPUnit\Util\\'; - $getMode = function () use ($mode) { - static $memoizedMode = false; + $handler = new self(); + $oldErrorHandler = set_error_handler([$handler, 'handleError']); - if (false !== $memoizedMode) { - return $memoizedMode; - } - if (false === $mode) { - $mode = getenv('SYMFONY_DEPRECATIONS_HELPER'); - } - if (DeprecationErrorHandler::MODE_DISABLED !== $mode - && DeprecationErrorHandler::MODE_WEAK !== $mode - && DeprecationErrorHandler::MODE_WEAK_VENDORS !== $mode - && (!isset($mode[0]) || '/' !== $mode[0]) - ) { - $mode = preg_match('/^[1-9][0-9]*$/', $mode) ? (int) $mode : 0; - } - - return $memoizedMode = $mode; - }; + if (null !== $oldErrorHandler) { + restore_error_handler(); - $inVendors = function ($path) { - /** @var string[] absolute paths to vendor directories */ - static $vendors; - if (null === $vendors) { - foreach (get_declared_classes() as $class) { - if ('C' === $class[0] && 0 === strpos($class, 'ComposerAutoloaderInit')) { - $r = new \ReflectionClass($class); - $v = \dirname(\dirname($r->getFileName())); - if (file_exists($v.'/composer/installed.json')) { - $vendors[] = $v; - } - } - } - } - $realPath = realpath($path); - if (false === $realPath && '-' !== $path && 'Standard input code' !== $path) { - return true; + if ([self::$utilPrefix.'ErrorHandler', 'handleError'] === $oldErrorHandler) { + restore_error_handler(); + self::register($mode); } - foreach ($vendors as $vendor) { - if (0 === strpos($realPath, $vendor) && false !== strpbrk(substr($realPath, \strlen($vendor), 1), '/'.\DIRECTORY_SEPARATOR)) { - return true; + } else { + $handler->mode = $mode; + self::$isRegistered = true; + register_shutdown_function([$handler, 'shutdown']); + } + } + + public static function collectDeprecations($outputFile) + { + $deprecations = []; + $previousErrorHandler = set_error_handler(function ($type, $msg, $file, $line, $context = []) use (&$deprecations, &$previousErrorHandler) { + if (E_USER_DEPRECATED !== $type && E_DEPRECATED !== $type) { + if ($previousErrorHandler) { + return $previousErrorHandler($type, $msg, $file, $line, $context); } - } - return false; - }; + static $autoload = true; - $deprecations = array( - 'unsilencedCount' => 0, - 'remainingCount' => 0, - 'legacyCount' => 0, - 'otherCount' => 0, - 'remaining vendorCount' => 0, - 'unsilenced' => array(), - 'remaining' => array(), - 'legacy' => array(), - 'other' => array(), - 'remaining vendor' => array(), - ); - $deprecationHandler = function ($type, $msg, $file, $line, $context = array()) use (&$deprecations, $getMode, $UtilPrefix, $inVendors) { - if ((E_USER_DEPRECATED !== $type && E_DEPRECATED !== $type) || DeprecationErrorHandler::MODE_DISABLED === $mode = $getMode()) { - $ErrorHandler = $UtilPrefix.'ErrorHandler'; + $ErrorHandler = class_exists('PHPUnit_Util_ErrorHandler', $autoload) ? 'PHPUnit_Util_ErrorHandler' : 'PHPUnit\Util\ErrorHandler'; + $autoload = false; return $ErrorHandler::handleError($type, $msg, $file, $line, $context); } - $trace = debug_backtrace(); - $group = 'other'; - $isVendor = DeprecationErrorHandler::MODE_WEAK_VENDORS === $mode && $inVendors($file); + $deprecations[] = [error_reporting(), $msg, $file]; + }); - $i = \count($trace); - while (1 < $i && (!isset($trace[--$i]['class']) || ('ReflectionMethod' === $trace[$i]['class'] || 0 === strpos($trace[$i]['class'], 'PHPUnit_') || 0 === strpos($trace[$i]['class'], 'PHPUnit\\')))) { - // No-op - } + register_shutdown_function(function () use ($outputFile, &$deprecations) { + file_put_contents($outputFile, serialize($deprecations)); + }); + } - if (isset($trace[$i]['object']) || isset($trace[$i]['class'])) { - if (isset($trace[$i]['class']) && 0 === strpos($trace[$i]['class'], 'Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerFor')) { - $parsedMsg = unserialize($msg); - $msg = $parsedMsg['deprecation']; - $class = $parsedMsg['class']; - $method = $parsedMsg['method']; - // If the deprecation has been triggered via - // \Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerTrait::endTest() - // then we need to use the serialized information to determine - // if the error has been triggered from vendor code. - $isVendor = DeprecationErrorHandler::MODE_WEAK_VENDORS === $mode && isset($parsedMsg['triggering_file']) && $inVendors($parsedMsg['triggering_file']); - } else { - $class = isset($trace[$i]['object']) ? \get_class($trace[$i]['object']) : $trace[$i]['class']; - $method = $trace[$i]['function']; - } + /** + * @internal + */ + public function handleError($type, $msg, $file, $line, $context = []) + { + if ((E_USER_DEPRECATED !== $type && E_DEPRECATED !== $type) || !$this->getConfiguration()->isEnabled()) { + $ErrorHandler = self::$utilPrefix.'ErrorHandler'; - $Test = $UtilPrefix.'Test'; - - if (0 !== error_reporting()) { - $group = 'unsilenced'; - } elseif (0 === strpos($method, 'testLegacy') - || 0 === strpos($method, 'provideLegacy') - || 0 === strpos($method, 'getLegacy') - || strpos($class, '\Legacy') - || \in_array('legacy', $Test::getGroups($class, $method), true) - ) { - $group = 'legacy'; - } elseif ($isVendor) { - $group = 'remaining vendor'; - } else { - $group = 'remaining'; - } + return $ErrorHandler::handleError($type, $msg, $file, $line, $context); + } - if (isset($mode[0]) && '/' === $mode[0] && preg_match($mode, $msg)) { - $e = new \Exception($msg); - $r = new \ReflectionProperty($e, 'trace'); - $r->setAccessible(true); - $r->setValue($e, \array_slice($trace, 1, $i)); + $deprecation = new Deprecation($msg, debug_backtrace(), $file); + $group = 'other'; - echo "\n".ucfirst($group).' deprecation triggered by '.$class.'::'.$method.':'; - echo "\n".$msg; - echo "\nStack trace:"; - echo "\n".str_replace(' '.getcwd().\DIRECTORY_SEPARATOR, ' ', $e->getTraceAsString()); - echo "\n"; + if ($deprecation->originatesFromAnObject()) { + $class = $deprecation->originatingClass(); + $method = $deprecation->originatingMethod(); - exit(1); - } - if ('legacy' !== $group && DeprecationErrorHandler::MODE_WEAK !== $mode) { - $ref = &$deprecations[$group][$msg]['count']; - ++$ref; - $ref = &$deprecations[$group][$msg][$class.'::'.$method]; - ++$ref; - } - } elseif (DeprecationErrorHandler::MODE_WEAK !== $mode) { - $ref = &$deprecations[$group][$msg]['count']; - ++$ref; + if (0 !== error_reporting()) { + $group = 'unsilenced'; + } elseif ($deprecation->isLegacy(self::$utilPrefix)) { + $group = 'legacy'; + } elseif (!$deprecation->isSelf()) { + $group = $deprecation->isIndirect() ? 'remaining indirect' : 'remaining direct'; + } else { + $group = 'remaining self'; } - ++$deprecations[$group.'Count']; - }; - $oldErrorHandler = set_error_handler($deprecationHandler); - if (null !== $oldErrorHandler) { - restore_error_handler(); - if (array($UtilPrefix.'ErrorHandler', 'handleError') === $oldErrorHandler) { - restore_error_handler(); - self::register($mode); + if ($this->getConfiguration()->shouldDisplayStackTrace($msg)) { + echo "\n".ucfirst($group).' '.$deprecation->toString(); + + exit(1); + } + if ('legacy' !== $group) { + $ref = &$this->deprecations[$group][$msg]['count']; + ++$ref; + $ref = &$this->deprecations[$group][$msg][$class.'::'.$method]; + ++$ref; } } else { - self::$isRegistered = true; - if (self::hasColorSupport()) { - $colorize = function ($str, $red) { - $color = $red ? '41;37' : '43;30'; + $ref = &$this->deprecations[$group][$msg]['count']; + ++$ref; + } - return "\x1B[{$color}m{$str}\x1B[0m"; - }; - } else { - $colorize = function ($str) { return $str; }; - } - register_shutdown_function(function () use ($getMode, &$deprecations, $deprecationHandler, $colorize) { - $mode = $getMode(); - if (isset($mode[0]) && '/' === $mode[0]) { - return; - } - $currErrorHandler = set_error_handler('var_dump'); - restore_error_handler(); + ++$this->deprecations[$group.'Count']; + } - if (DeprecationErrorHandler::MODE_WEAK === $mode) { - $colorize = function ($str) { return $str; }; - } - if ($currErrorHandler !== $deprecationHandler) { - echo "\n", $colorize('THE ERROR HANDLER HAS CHANGED!', true), "\n"; - } + /** + * @internal + */ + public function shutdown() + { + $configuration = $this->getConfiguration(); - $cmp = function ($a, $b) { - return $b['count'] - $a['count']; - }; + if ($configuration->isInRegexMode()) { + return; + } - $groups = array('unsilenced', 'remaining'); - if (DeprecationErrorHandler::MODE_WEAK_VENDORS === $mode) { - $groups[] = 'remaining vendor'; - } - array_push($groups, 'legacy', 'other'); + $currErrorHandler = set_error_handler('var_dump'); + restore_error_handler(); - $displayDeprecations = function ($deprecations) use ($colorize, $cmp, $groups) { - foreach ($groups as $group) { - if ($deprecations[$group.'Count']) { - echo "\n", $colorize( - sprintf('%s deprecation notices (%d)', ucfirst($group), $deprecations[$group.'Count']), - 'legacy' !== $group && 'remaining vendor' !== $group - ), "\n"; + if ($currErrorHandler !== [$this, 'handleError']) { + echo "\n", self::colorize('THE ERROR HANDLER HAS CHANGED!', true), "\n"; + } - uasort($deprecations[$group], $cmp); + $groups = ['unsilenced', 'remaining self', 'remaining direct', 'remaining indirect', 'legacy', 'other']; - foreach ($deprecations[$group] as $msg => $notices) { - echo "\n ", $notices['count'], 'x: ', $msg, "\n"; + $this->displayDeprecations($groups, $configuration); - arsort($notices); + // store failing status + $isFailing = !$configuration->tolerates($this->deprecations); - foreach ($notices as $method => $count) { - if ('count' !== $method) { - echo ' ', $count, 'x in ', preg_replace('/(.*)\\\\(.*?::.*?)$/', '$2 from $1', $method), "\n"; - } - } - } - } - } - if (!empty($notices)) { - echo "\n"; - } - }; + // reset deprecations array + foreach ($this->deprecations as $group => $arrayOrInt) { + $this->deprecations[$group] = \is_int($arrayOrInt) ? 0 : []; + } - $displayDeprecations($deprecations); + register_shutdown_function(function () use ($isFailing, $groups, $configuration) { + foreach ($this->deprecations as $group => $arrayOrInt) { + if (0 < (\is_int($arrayOrInt) ? $arrayOrInt : \count($arrayOrInt))) { + echo "Shutdown-time deprecations:\n"; + break; + } + } - // store failing status - $isFailing = DeprecationErrorHandler::MODE_WEAK !== $mode && $mode < $deprecations['unsilencedCount'] + $deprecations['remainingCount'] + $deprecations['otherCount']; + $this->displayDeprecations($groups, $configuration); - // reset deprecations array - foreach ($deprecations as $group => $arrayOrInt) { - $deprecations[$group] = \is_int($arrayOrInt) ? 0 : array(); - } + if ($isFailing || !$configuration->tolerates($this->deprecations)) { + exit(1); + } + }); + } - register_shutdown_function(function () use (&$deprecations, $isFailing, $displayDeprecations, $mode) { - foreach ($deprecations as $group => $arrayOrInt) { - if (0 < (\is_int($arrayOrInt) ? $arrayOrInt : \count($arrayOrInt))) { - echo "Shutdown-time deprecations:\n"; - break; - } - } - $displayDeprecations($deprecations); - if ($isFailing || DeprecationErrorHandler::MODE_WEAK !== $mode && $mode < $deprecations['unsilencedCount'] + $deprecations['remainingCount'] + $deprecations['otherCount']) { - exit(1); - } - }); - }); + private function getConfiguration() + { + if (null !== $this->configuration) { + return $this->configuration; + } + if (false === $mode = $this->mode) { + $mode = getenv('SYMFONY_DEPRECATIONS_HELPER'); + } + if ('strict' === $mode) { + return $this->configuration = Configuration::inStrictMode(); + } + if (self::MODE_DISABLED === $mode) { + return $this->configuration = Configuration::inDisabledMode(); } + if ('weak' === $mode) { + return $this->configuration = Configuration::inWeakMode(); + } + if (self::MODE_WEAK_VENDORS === $mode) { + ++$this->deprecations['remaining selfCount']; + $msg = sprintf('Setting SYMFONY_DEPRECATIONS_HELPER to "%s" is deprecated in favor of "max[self]=0"', $mode); + $ref = &$this->deprecations['remaining self'][$msg]['count']; + ++$ref; + $mode = 'max[self]=0'; + } + if (isset($mode[0]) && '/' === $mode[0]) { + return $this->configuration = Configuration::fromRegex($mode); + } + + if (preg_match('/^[1-9][0-9]*$/', (string) $mode)) { + return $this->configuration = Configuration::fromNumber($mode); + } + + if (!$mode) { + return $this->configuration = Configuration::fromNumber(0); + } + + return $this->configuration = Configuration::fromUrlEncodedString((string) $mode); } - public static function collectDeprecations($outputFile) + /** + * @param string $str + * @param bool $red + * + * @return string + */ + private static function colorize($str, $red) { - $deprecations = array(); - $previousErrorHandler = set_error_handler(function ($type, $msg, $file, $line, $context = array()) use (&$deprecations, &$previousErrorHandler) { - if (E_USER_DEPRECATED !== $type && E_DEPRECATED !== $type) { - if ($previousErrorHandler) { - return $previousErrorHandler($type, $msg, $file, $line, $context); + if (!self::hasColorSupport()) { + return $str; + } + + $color = $red ? '41;37' : '43;30'; + + return "\x1B[{$color}m{$str}\x1B[0m"; + } + + /** + * @param string[] $groups + * @param Configuration $configuration + */ + private function displayDeprecations($groups, $configuration) + { + $cmp = function ($a, $b) { + return $b['count'] - $a['count']; + }; + + foreach ($groups as $group) { + if ($this->deprecations[$group.'Count']) { + echo "\n", self::colorize( + sprintf('%s deprecation notices (%d)', ucfirst($group), $this->deprecations[$group.'Count']), + 'legacy' !== $group && 'remaining indirect' !== $group + ), "\n"; + + if (!$configuration->verboseOutput()) { + continue; } - static $autoload = true; + uasort($this->deprecations[$group], $cmp); - $ErrorHandler = class_exists('PHPUnit_Util_ErrorHandler', $autoload) ? 'PHPUnit_Util_ErrorHandler' : 'PHPUnit\Util\ErrorHandler'; - $autoload = false; + foreach ($this->deprecations[$group] as $msg => $notices) { + echo "\n ", $notices['count'], 'x: ', $msg, "\n"; - return $ErrorHandler::handleError($type, $msg, $file, $line, $context); + arsort($notices); + + foreach ($notices as $method => $count) { + if ('count' !== $method) { + echo ' ', $count, 'x in ', preg_replace('/(.*)\\\\(.*?::.*?)$/', '$2 from $1', $method), "\n"; + } + } + } } - $deprecations[] = array(error_reporting(), $msg, $file); - }); + } - register_shutdown_function(function () use ($outputFile, &$deprecations) { - file_put_contents($outputFile, serialize($deprecations)); - }); + if (!empty($notices)) { + echo "\n"; + } } /** @@ -335,6 +341,7 @@ private static function hasColorSupport() } $stat = fstat(STDOUT); + // Check if formatted mode is S_IFCHR return $stat ? 0020000 === ($stat['mode'] & 0170000) : false; } diff --git a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Configuration.php b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Configuration.php new file mode 100644 index 0000000000000..44f0341205aa7 --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Configuration.php @@ -0,0 +1,208 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit\DeprecationErrorHandler; + +/** + * @internal + */ +class Configuration +{ + /** + * @var int[] + */ + private $thresholds; + + /** + * @var string + */ + private $regex; + + /** + * @var bool + */ + private $enabled = true; + + /** + * @var bool + */ + private $verboseOutput = true; + + /** + * @param int[] $thresholds A hash associating groups to thresholds + * @param string $regex Will be matched against messages, to decide + * whether to display a stack trace + * @param bool $verboseOutput + */ + private function __construct(array $thresholds = [], $regex = '', $verboseOutput = true) + { + $groups = ['total', 'indirect', 'direct', 'self']; + + foreach ($thresholds as $group => $threshold) { + if (!\in_array($group, $groups, true)) { + throw new \InvalidArgumentException(sprintf('Unrecognized threshold "%s", expected one of "%s"', $group, implode('", "', $groups))); + } + if (!is_numeric($threshold)) { + throw new \InvalidArgumentException(sprintf('Threshold for group "%s" has invalid value "%s"', $group, $threshold)); + } + $this->thresholds[$group] = (int) $threshold; + } + if (isset($this->thresholds['direct'])) { + $this->thresholds += [ + 'self' => $this->thresholds['direct'], + ]; + } + if (isset($this->thresholds['indirect'])) { + $this->thresholds += [ + 'direct' => $this->thresholds['indirect'], + 'self' => $this->thresholds['indirect'], + ]; + } + foreach ($groups as $group) { + if (!isset($this->thresholds[$group])) { + $this->thresholds[$group] = 999999; + } + } + $this->regex = $regex; + $this->verboseOutput = $verboseOutput; + } + + /** + * @return bool + */ + public function isEnabled() + { + return $this->enabled; + } + + /** + * @param mixed[] $deprecations + * + * @return bool + */ + public function tolerates(array $deprecations) + { + $deprecationCounts = array_filter($deprecations, function ($key) { + return false !== strpos($key, 'Count') && false === strpos($key, 'legacy'); + }, ARRAY_FILTER_USE_KEY); + + if (array_sum($deprecationCounts) > $this->thresholds['total']) { + return false; + } + foreach (['self', 'direct', 'indirect'] as $deprecationType) { + if ($deprecationCounts['remaining '.$deprecationType.'Count'] > $this->thresholds[$deprecationType]) { + return false; + } + } + + return true; + } + + /** + * @param string $message + * + * @return bool + */ + public function shouldDisplayStackTrace($message) + { + return '' !== $this->regex && preg_match($this->regex, $message); + } + + /** + * @return bool + */ + public function isInRegexMode() + { + return '' !== $this->regex; + } + + /** + * @return bool + */ + public function verboseOutput() + { + return $this->verboseOutput; + } + + /** + * @param string $serializedConfiguration an encoded string, for instance + * max[total]=1234&max[indirect]=42 + * + * @return self + */ + public static function fromUrlEncodedString($serializedConfiguration) + { + parse_str($serializedConfiguration, $normalizedConfiguration); + foreach (array_keys($normalizedConfiguration) as $key) { + if (!\in_array($key, ['max', 'disabled', 'verbose'], true)) { + throw new \InvalidArgumentException(sprintf('Unknown configuration option "%s"', $key)); + } + } + + if (isset($normalizedConfiguration['disabled'])) { + return self::inDisabledMode(); + } + + $verboseOutput = true; + if (isset($normalizedConfiguration['verbose'])) { + $verboseOutput = (bool) $normalizedConfiguration['verbose']; + } + + return new self( + isset($normalizedConfiguration['max']) ? $normalizedConfiguration['max'] : [], + '', + $verboseOutput + ); + } + + /** + * @return self + */ + public static function inDisabledMode() + { + $configuration = new self(); + $configuration->enabled = false; + + return $configuration; + } + + /** + * @return self + */ + public static function inStrictMode() + { + return new self(['total' => 0]); + } + + /** + * @return self + */ + public static function inWeakMode() + { + return new self([], '', false); + } + + /** + * @return self + */ + public static function fromNumber($upperBound) + { + return new self(['total' => $upperBound]); + } + + /** + * @return self + */ + public static function fromRegex($regex) + { + return new self([], $regex); + } +} diff --git a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Deprecation.php b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Deprecation.php new file mode 100644 index 0000000000000..e267a3b8db46b --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Deprecation.php @@ -0,0 +1,291 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit\DeprecationErrorHandler; + +use Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerFor; + +/** + * @internal + */ +class Deprecation +{ + /** + * @var array + */ + private $trace; + + /** + * @var string + */ + private $message; + + /** + * @var ?string + */ + private $originClass; + + /** + * @var ?string + */ + private $originMethod; + + /** + * @var bool + */ + private $self; + + /** @var string[] absolute paths to vendor directories */ + private static $vendors; + + /** + * @param string $message + * @param string $file + */ + public function __construct($message, array $trace, $file) + { + $this->trace = $trace; + $this->message = $message; + $i = \count($trace); + while (1 < $i && $this->lineShouldBeSkipped($trace[--$i])) { + // No-op + } + $line = $trace[$i]; + $this->self = !$this->pathOriginatesFromVendor($file); + if (isset($line['object']) || isset($line['class'])) { + if (isset($line['class']) && 0 === strpos($line['class'], SymfonyTestsListenerFor::class)) { + $parsedMsg = unserialize($this->message); + $this->message = $parsedMsg['deprecation']; + $this->originClass = $parsedMsg['class']; + $this->originMethod = $parsedMsg['method']; + // If the deprecation has been triggered via + // \Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerTrait::endTest() + // then we need to use the serialized information to determine + // if the error has been triggered from vendor code. + $this->self = isset($parsedMsg['triggering_file']) + && $this->pathOriginatesFromVendor($parsedMsg['triggering_file']); + + return; + } + $this->originClass = isset($line['object']) ? \get_class($line['object']) : $line['class']; + $this->originMethod = $line['function']; + } + } + + /** + * @return bool + */ + private function lineShouldBeSkipped(array $line) + { + if (!isset($line['class'])) { + return true; + } + $class = $line['class']; + + return 'ReflectionMethod' === $class || 0 === strpos($class, 'PHPUnit_') || 0 === strpos($class, 'PHPUnit\\'); + } + + /** + * @return bool + */ + public function originatesFromAnObject() + { + return isset($this->originClass); + } + + /** + * @return bool + */ + public function isSelf() + { + return $this->self; + } + + /** + * @return string + */ + public function originatingClass() + { + if (null === $this->originClass) { + throw new \LogicException('Check with originatesFromAnObject() before calling this method'); + } + + return $this->originClass; + } + + /** + * @return string + */ + public function originatingMethod() + { + if (null === $this->originMethod) { + throw new \LogicException('Check with originatesFromAnObject() before calling this method'); + } + + return $this->originMethod; + } + + /** + * @param string $utilPrefix + * + * @return bool + */ + public function isLegacy($utilPrefix) + { + $test = $utilPrefix.'Test'; + $class = $this->originatingClass(); + $method = $this->originatingMethod(); + + return 0 === strpos($method, 'testLegacy') + || 0 === strpos($method, 'provideLegacy') + || 0 === strpos($method, 'getLegacy') + || strpos($class, '\Legacy') + || \in_array('legacy', $test::getGroups($class, $method), true); + } + + /** + * Tells whether both the calling package and the called package are vendor + * packages. + * + * @return bool + */ + public function isIndirect() + { + $erroringFile = $erroringPackage = null; + foreach ($this->trace as $line) { + if (\in_array($line['function'], ['require', 'require_once', 'include', 'include_once'], true)) { + continue; + } + if (!isset($line['file'])) { + continue; + } + $file = $line['file']; + if ('-' === $file || 'Standard input code' === $file || !realpath($file)) { + continue; + } + if (!$this->pathOriginatesFromVendor($file)) { + return false; + } + if (null !== $erroringFile && null !== $erroringPackage) { + $package = $this->getPackage($file); + if ('composer' !== $package && $package !== $erroringPackage) { + return true; + } + continue; + } + $erroringFile = $file; + $erroringPackage = $this->getPackage($file); + } + + return false; + } + + /** + * pathOriginatesFromVendor() should always be called prior to calling this method. + * + * @param string $path + * + * @return string + */ + private function getPackage($path) + { + $path = realpath($path) ?: $path; + foreach (self::getVendors() as $vendorRoot) { + if (0 === strpos($path, $vendorRoot)) { + $relativePath = substr($path, \strlen($vendorRoot) + 1); + $vendor = strstr($relativePath, \DIRECTORY_SEPARATOR, true); + if (false === $vendor) { + throw new \RuntimeException(sprintf('Could not find directory separator "%s" in path "%s"', \DIRECTORY_SEPARATOR, $relativePath)); + } + + return rtrim($vendor.'/'.strstr(substr( + $relativePath, + \strlen($vendor) + 1 + ), \DIRECTORY_SEPARATOR, true), '/'); + } + } + + throw new \RuntimeException(sprintf('No vendors found for path "%s"', $path)); + } + + /** + * @return string[] an array of paths + */ + private static function getVendors() + { + if (null === self::$vendors) { + self::$vendors = []; + foreach (get_declared_classes() as $class) { + if ('C' === $class[0] && 0 === strpos($class, 'ComposerAutoloaderInit')) { + $r = new \ReflectionClass($class); + $v = \dirname(\dirname($r->getFileName())); + if (file_exists($v.'/composer/installed.json')) { + self::$vendors[] = $v; + } + } + } + } + + return self::$vendors; + } + + /** + * @param string $path + * + * @return bool + */ + private function pathOriginatesFromVendor($path) + { + $realPath = realpath($path); + if (false === $realPath && '-' !== $path && 'Standard input code' !== $path) { + return true; + } + foreach (self::getVendors() as $vendor) { + if (0 === strpos($realPath, $vendor) && false !== strpbrk(substr($realPath, \strlen($vendor), 1), '/'.\DIRECTORY_SEPARATOR)) { + return true; + } + } + + return false; + } + + /** + * @return string + */ + public function toString() + { + $exception = new \Exception($this->message); + $reflection = new \ReflectionProperty($exception, 'trace'); + $reflection->setAccessible(true); + $reflection->setValue($exception, $this->trace); + + return 'deprecation triggered by '.$this->originatingClass().'::'.$this->originatingMethod().':'. + "\n".$this->message. + "\nStack trace:". + "\n".str_replace(' '.getcwd().\DIRECTORY_SEPARATOR, ' ', $exception->getTraceAsString()). + "\n"; + } + + private function getPackageFromLine(array $line) + { + if (!isset($line['file'])) { + return 'internal function'; + } + if (!$this->pathOriginatesFromVendor($line['file'])) { + return 'source code'; + } + try { + return $this->getPackage($line['file']); + } catch (\RuntimeException $e) { + return 'unknown'; + } + } +} diff --git a/src/Symfony/Bridge/PhpUnit/Tests/ClassExistsMockTest.php b/src/Symfony/Bridge/PhpUnit/Tests/ClassExistsMockTest.php new file mode 100644 index 0000000000000..002d313a6fa01 --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/ClassExistsMockTest.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ClassExistsMock; + +class ClassExistsMockTest extends TestCase +{ + public static function setUpBeforeClass() + { + ClassExistsMock::register(__CLASS__); + } + + protected function setUp() + { + ClassExistsMock::withMockedClasses([ + ExistingClass::class => false, + 'NonExistingClass' => true, + ExistingInterface::class => false, + 'NonExistingInterface' => true, + ExistingTrait::class => false, + 'NonExistingTrait' => true, + ]); + } + + public function testClassExists() + { + $this->assertFalse(class_exists(ExistingClass::class)); + $this->assertFalse(class_exists(ExistingClass::class, false)); + $this->assertFalse(class_exists('\\'.ExistingClass::class)); + $this->assertFalse(class_exists('\\'.ExistingClass::class, false)); + $this->assertTrue(class_exists('NonExistingClass')); + $this->assertTrue(class_exists('NonExistingClass', false)); + $this->assertTrue(class_exists('\\NonExistingClass')); + $this->assertTrue(class_exists('\\NonExistingClass', false)); + $this->assertTrue(class_exists(ExistingClassReal::class)); + $this->assertTrue(class_exists(ExistingClassReal::class, false)); + $this->assertTrue(class_exists('\\'.ExistingClassReal::class)); + $this->assertTrue(class_exists('\\'.ExistingClassReal::class, false)); + $this->assertFalse(class_exists('NonExistingClassReal')); + $this->assertFalse(class_exists('NonExistingClassReal', false)); + $this->assertFalse(class_exists('\\NonExistingClassReal')); + $this->assertFalse(class_exists('\\NonExistingClassReal', false)); + } + + public function testInterfaceExists() + { + $this->assertFalse(interface_exists(ExistingInterface::class)); + $this->assertFalse(interface_exists(ExistingInterface::class, false)); + $this->assertFalse(interface_exists('\\'.ExistingInterface::class)); + $this->assertFalse(interface_exists('\\'.ExistingInterface::class, false)); + $this->assertTrue(interface_exists('NonExistingInterface')); + $this->assertTrue(interface_exists('NonExistingInterface', false)); + $this->assertTrue(interface_exists('\\NonExistingInterface')); + $this->assertTrue(interface_exists('\\NonExistingInterface', false)); + $this->assertTrue(interface_exists(ExistingInterfaceReal::class)); + $this->assertTrue(interface_exists(ExistingInterfaceReal::class, false)); + $this->assertTrue(interface_exists('\\'.ExistingInterfaceReal::class)); + $this->assertTrue(interface_exists('\\'.ExistingInterfaceReal::class, false)); + $this->assertFalse(interface_exists('NonExistingClassReal')); + $this->assertFalse(interface_exists('NonExistingClassReal', false)); + $this->assertFalse(interface_exists('\\NonExistingInterfaceReal')); + $this->assertFalse(interface_exists('\\NonExistingInterfaceReal', false)); + } + + public function testTraitExists() + { + $this->assertFalse(trait_exists(ExistingTrait::class)); + $this->assertFalse(trait_exists(ExistingTrait::class, false)); + $this->assertFalse(trait_exists('\\'.ExistingTrait::class)); + $this->assertFalse(trait_exists('\\'.ExistingTrait::class, false)); + $this->assertTrue(trait_exists('NonExistingTrait')); + $this->assertTrue(trait_exists('NonExistingTrait', false)); + $this->assertTrue(trait_exists('\\NonExistingTrait')); + $this->assertTrue(trait_exists('\\NonExistingTrait', false)); + $this->assertTrue(trait_exists(ExistingTraitReal::class)); + $this->assertTrue(trait_exists(ExistingTraitReal::class, false)); + $this->assertTrue(trait_exists('\\'.ExistingTraitReal::class)); + $this->assertTrue(trait_exists('\\'.ExistingTraitReal::class, false)); + $this->assertFalse(trait_exists('NonExistingClassReal')); + $this->assertFalse(trait_exists('NonExistingClassReal', false)); + $this->assertFalse(trait_exists('\\NonExistingTraitReal')); + $this->assertFalse(trait_exists('\\NonExistingTraitReal', false)); + } +} + +class ExistingClass +{ +} + +class ExistingClassReal +{ +} + +interface ExistingInterface +{ +} + +interface ExistingInterfaceReal +{ +} + +trait ExistingTrait +{ +} + +trait ExistingTraitReal +{ +} diff --git a/src/Symfony/Bridge/PhpUnit/Tests/ClockMockTest.php b/src/Symfony/Bridge/PhpUnit/Tests/ClockMockTest.php index 4c77e99f5e20f..5b92ccd8507e4 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/ClockMockTest.php +++ b/src/Symfony/Bridge/PhpUnit/Tests/ClockMockTest.php @@ -62,4 +62,11 @@ public function testDate() { $this->assertSame('1234567890', date('U')); } + + public function testGmDate() + { + ClockMock::withClockMock(1555075769); + + $this->assertSame('1555075769', gmdate('U')); + } } diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/ConfigurationTest.php b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/ConfigurationTest.php new file mode 100644 index 0000000000000..39e792cd3a2cb --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/ConfigurationTest.php @@ -0,0 +1,195 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit\Tests\DeprecationErrorHandler; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\Configuration; + +class ConfigurationTest extends TestCase +{ + public function testItThrowsOnStringishValue() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('hi'); + Configuration::fromUrlEncodedString('hi'); + } + + public function testItThrowsOnUnknownConfigurationOption() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('min'); + Configuration::fromUrlEncodedString('min[total]=42'); + } + + public function testItThrowsOnUnknownThreshold() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('deep'); + Configuration::fromUrlEncodedString('max[deep]=42'); + } + + public function testItThrowsOnStringishThreshold() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('forty-two'); + Configuration::fromUrlEncodedString('max[total]=forty-two'); + } + + public function testItNoticesExceededTotalThreshold() + { + $configuration = Configuration::fromUrlEncodedString('max[total]=3'); + $this->assertTrue($configuration->tolerates([ + 'unsilencedCount' => 1, + 'remaining selfCount' => 0, + 'legacyCount' => 1, + 'otherCount' => 0, + 'remaining directCount' => 1, + 'remaining indirectCount' => 1, + ])); + $this->assertFalse($configuration->tolerates([ + 'unsilencedCount' => 1, + 'remaining selfCount' => 1, + 'legacyCount' => 1, + 'otherCount' => 0, + 'remaining directCount' => 1, + 'remaining indirectCount' => 1, + ])); + } + + public function testItNoticesExceededSelfThreshold() + { + $configuration = Configuration::fromUrlEncodedString('max[self]=1'); + $this->assertTrue($configuration->tolerates([ + 'unsilencedCount' => 1234, + 'remaining selfCount' => 1, + 'legacyCount' => 23, + 'otherCount' => 13, + 'remaining directCount' => 124, + 'remaining indirectCount' => 3244, + ])); + $this->assertFalse($configuration->tolerates([ + 'unsilencedCount' => 1234, + 'remaining selfCount' => 2, + 'legacyCount' => 23, + 'otherCount' => 13, + 'remaining directCount' => 124, + 'remaining indirectCount' => 3244, + ])); + } + + public function testItNoticesExceededDirectThreshold() + { + $configuration = Configuration::fromUrlEncodedString('max[direct]=1&max[self]=999999'); + $this->assertTrue($configuration->tolerates([ + 'unsilencedCount' => 1234, + 'remaining selfCount' => 123, + 'legacyCount' => 23, + 'otherCount' => 13, + 'remaining directCount' => 1, + 'remaining indirectCount' => 3244, + ])); + $this->assertFalse($configuration->tolerates([ + 'unsilencedCount' => 1234, + 'remaining selfCount' => 124, + 'legacyCount' => 23, + 'otherCount' => 13, + 'remaining directCount' => 2, + 'remaining indirectCount' => 3244, + ])); + } + + public function testItNoticesExceededIndirectThreshold() + { + $configuration = Configuration::fromUrlEncodedString('max[indirect]=1&max[direct]=999999&max[self]=999999'); + $this->assertTrue($configuration->tolerates([ + 'unsilencedCount' => 1234, + 'remaining selfCount' => 123, + 'legacyCount' => 23, + 'otherCount' => 13, + 'remaining directCount' => 1234, + 'remaining indirectCount' => 1, + ])); + $this->assertFalse($configuration->tolerates([ + 'unsilencedCount' => 1234, + 'remaining selfCount' => 124, + 'legacyCount' => 23, + 'otherCount' => 13, + 'remaining directCount' => 2324, + 'remaining indirectCount' => 2, + ])); + } + + public function testIndirectThresholdIsUsedAsADefaultForDirectAndSelfThreshold() + { + $configuration = Configuration::fromUrlEncodedString('max[indirect]=1'); + $this->assertTrue($configuration->tolerates([ + 'unsilencedCount' => 0, + 'remaining selfCount' => 1, + 'legacyCount' => 0, + 'otherCount' => 0, + 'remaining directCount' => 0, + 'remaining indirectCount' => 0, + ])); + $this->assertFalse($configuration->tolerates([ + 'unsilencedCount' => 0, + 'remaining selfCount' => 2, + 'legacyCount' => 0, + 'otherCount' => 0, + 'remaining directCount' => 0, + 'remaining indirectCount' => 0, + ])); + $this->assertTrue($configuration->tolerates([ + 'unsilencedCount' => 0, + 'remaining selfCount' => 0, + 'legacyCount' => 0, + 'otherCount' => 0, + 'remaining directCount' => 1, + 'remaining indirectCount' => 0, + ])); + $this->assertFalse($configuration->tolerates([ + 'unsilencedCount' => 0, + 'remaining selfCount' => 0, + 'legacyCount' => 0, + 'otherCount' => 0, + 'remaining directCount' => 2, + 'remaining indirectCount' => 0, + ])); + } + + public function testItCanTellWhetherToDisplayAStackTrace() + { + $configuration = Configuration::fromUrlEncodedString(''); + $this->assertFalse($configuration->shouldDisplayStackTrace('interesting')); + + $configuration = Configuration::fromRegex('/^interesting/'); + $this->assertFalse($configuration->shouldDisplayStackTrace('uninteresting')); + $this->assertTrue($configuration->shouldDisplayStackTrace('interesting')); + } + + public function testItCanBeDisabled() + { + $configuration = Configuration::fromUrlEncodedString('disabled'); + $this->assertFalse($configuration->isEnabled()); + } + + public function testItCanBeShushed() + { + $configuration = Configuration::fromUrlEncodedString('verbose'); + $this->assertFalse($configuration->verboseOutput()); + } + + public function testOutputIsNotVerboseInWeakMode() + { + $configuration = Configuration::inWeakMode(); + $this->assertFalse($configuration->verboseOutput()); + } +} diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/DeprecationTest.php b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/DeprecationTest.php new file mode 100644 index 0000000000000..92bad71e08498 --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/DeprecationTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit\Tests\DeprecationErrorHandler; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\Deprecation; + +class DeprecationTest extends TestCase +{ + public function testItCanDetermineTheClassWhereTheDeprecationHappened() + { + $deprecation = new Deprecation('💩', $this->debugBacktrace(), __FILE__); + $this->assertTrue($deprecation->originatesFromAnObject()); + $this->assertSame(self::class, $deprecation->originatingClass()); + $this->assertSame(__FUNCTION__, $deprecation->originatingMethod()); + } + + public function testItCanTellWhetherItIsInternal() + { + $deprecation = new Deprecation('💩', $this->debugBacktrace(), __FILE__); + $this->assertTrue($deprecation->isSelf()); + } + + public function testLegacyTestMethodIsDetectedAsSuch() + { + $deprecation = new Deprecation('💩', $this->debugBacktrace(), __FILE__); + $this->assertTrue($deprecation->isLegacy('whatever')); + } + + public function testItCanBeConvertedToAString() + { + $deprecation = new Deprecation('💩', $this->debugBacktrace(), __FILE__); + $this->assertContains('💩', $deprecation->toString()); + $this->assertContains(__FUNCTION__, $deprecation->toString()); + } + + public function testItRulesOutFilesOutsideVendorsAsIndirect() + { + $deprecation = new Deprecation('💩', $this->debugBacktrace(), __FILE__); + $this->assertFalse($deprecation->isIndirect()); + } + + /** + * This method is here to simulate the extra level from the piece of code + * triggering an error to the error handler + */ + public function debugBacktrace(): array + { + return debug_backtrace(); + } +} diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/default.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/default.phpt index 7a0595a7ddebc..e9f7bec9664c6 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/default.phpt +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/default.phpt @@ -73,7 +73,7 @@ Unsilenced deprecation notices (3) 1x: unsilenced bar deprecation 1x in FooTestCase::testNonLegacyBar -Remaining deprecation notices (1) +Remaining self deprecation notices (1) 1x: silenced bar deprecation 1x in FooTestCase::testNonLegacyBar diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/regexp.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/deprecated_regexp.phpt similarity index 100% rename from src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/regexp.phpt rename to src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/deprecated_regexp.phpt diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/eval_not_self.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/eval_not_self.phpt new file mode 100644 index 0000000000000..8d823feb2c97e --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/eval_not_self.phpt @@ -0,0 +1,24 @@ +--TEST-- +Test eval()'d deprecation is not considered as self +--FILE-- + +--EXPECTF-- +Other deprecation notices (1) + + 1x: who knows where I come from? diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/fake_vendor/acme/lib/SomeService.php b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/fake_vendor/acme/lib/SomeService.php new file mode 100644 index 0000000000000..6a354103ff3ce --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/fake_vendor/acme/lib/SomeService.php @@ -0,0 +1,14 @@ +deprecatedApi(); diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/lagging_vendor.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/lagging_vendor.phpt new file mode 100644 index 0000000000000..37488e1d160ec --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/lagging_vendor.phpt @@ -0,0 +1,38 @@ +--TEST-- +Test DeprecationErrorHandler in weak vendors mode on vendor file +--FILE-- + +--EXPECTF-- +Remaining indirect deprecation notices (1) + + 1x: deprecatedApi is deprecated! You should stop relying on it! + 1x in SomeService::deprecatedApi from acme\lib diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/quiet.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/quiet.phpt new file mode 100644 index 0000000000000..b7e22a711df20 --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/quiet.phpt @@ -0,0 +1,39 @@ +--TEST-- +Test DeprecationErrorHandler with quiet output +--FILE-- +testLegacyFoo(); + +?> +--EXPECTF-- +Unsilenced deprecation notices (1) + +Legacy deprecation notices (1) + +Other deprecation notices (1) diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/self_on_non_vendor.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/self_on_non_vendor.phpt new file mode 100644 index 0000000000000..cb21ea8c21bd1 --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/self_on_non_vendor.phpt @@ -0,0 +1,74 @@ +--TEST-- +Test DeprecationErrorHandler with no self deprecations on self deprecation +--FILE-- +testLegacyFoo(); +$foo->testNonLegacyBar(); + +?> +--EXPECTF-- +Unsilenced deprecation notices (3) + + 2x: unsilenced foo deprecation + 2x in FooTestCase::testLegacyFoo + + 1x: unsilenced bar deprecation + 1x in FooTestCase::testNonLegacyBar + +Remaining self deprecation notices (1) + + 1x: silenced bar deprecation + 1x in FooTestCase::testNonLegacyBar + +Legacy deprecation notices (1) + +Other deprecation notices (1) + + 1x: root deprecation + diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/shutdown_deprecations.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/shutdown_deprecations.phpt index fddeed6085dea..46e9691085aac 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/shutdown_deprecations.phpt +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/shutdown_deprecations.phpt @@ -73,7 +73,7 @@ Unsilenced deprecation notices (3) 1x: unsilenced bar deprecation 1x in FooTestCase::testNonLegacyBar -Remaining deprecation notices (1) +Remaining self deprecation notices (1) 1x: silenced bar deprecation 1x in FooTestCase::testNonLegacyBar diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_eval_d_deprecation.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_eval_d_deprecation.phpt index 8390d16332fa1..ab513b646c15d 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_eval_d_deprecation.phpt +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_eval_d_deprecation.phpt @@ -3,7 +3,7 @@ Test DeprecationErrorHandler in weak vendors mode on eval()'d deprecation --FILE-- --EXPECTF-- - Other deprecation notices (1) 1x: who knows where I come from? diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_non_vendor.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_non_vendor.phpt index e20c7adf6ba1f..4068a392b2c9f 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_non_vendor.phpt +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_non_vendor.phpt @@ -3,7 +3,7 @@ Test DeprecationErrorHandler in weak vendors mode on a non vendor file --FILE-- --EXPECTF-- - Other deprecation notices (1) 1x: I come from… afar! :D diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_vendor.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_vendor.phpt index 68e233df7d0d9..cb707610574eb 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_vendor.phpt +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/weak_vendors_on_vendor.phpt @@ -1,9 +1,9 @@ --TEST-- -Test DeprecationErrorHandler in weak vendors mode on vendor file +Test DeprecationErrorHandler with no self deprecations on vendor deprecation --FILE-- = 70200) { - // PHPUnit 6 is required for PHP 7.2+ +if (PHP_VERSION_ID >= 70100) { + // PHPUnit 7 requires PHP 7.1+ + $PHPUNIT_VERSION = $getEnvVar('SYMFONY_PHPUNIT_VERSION', '7.4'); +} elseif (PHP_VERSION_ID >= 70000) { + // PHPUnit 6 requires PHP 7.0+ $PHPUNIT_VERSION = $getEnvVar('SYMFONY_PHPUNIT_VERSION', '6.5'); } elseif (PHP_VERSION_ID >= 50600) { - // PHPUnit 4 does not support PHP 7 + // PHPUnit 5 requires PHP 5.6+ $PHPUNIT_VERSION = $getEnvVar('SYMFONY_PHPUNIT_VERSION', '5.7'); } else { - // PHPUnit 5.1 requires PHP 5.6+ $PHPUNIT_VERSION = '4.8'; } diff --git a/src/Symfony/Bridge/PhpUnit/composer.json b/src/Symfony/Bridge/PhpUnit/composer.json index c629e8719b5ab..496bb8a385039 100644 --- a/src/Symfony/Bridge/PhpUnit/composer.json +++ b/src/Symfony/Bridge/PhpUnit/composer.json @@ -16,9 +16,9 @@ } ], "require": { - "php": ">=5.3.3 EVEN ON LATEST SYMFONY VERSIONS TO ALLOW USING", + "php": ">=5.5.9 EVEN ON LATEST SYMFONY VERSIONS TO ALLOW USING", "php": "THIS BRIDGE WHEN TESTING LOWEST SYMFONY VERSIONS.", - "php": ">=5.3.3" + "php": ">=5.5.9" }, "suggest": { "symfony/debug": "For tracking deprecated interfaces usages at runtime with DebugClassLoader" @@ -39,7 +39,7 @@ "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" }, "thanks": { "name": "phpunit/phpunit", diff --git a/src/Symfony/Bridge/ProxyManager/composer.json b/src/Symfony/Bridge/ProxyManager/composer.json index 648bf8990fb64..d5ce7a3e3989f 100644 --- a/src/Symfony/Bridge/ProxyManager/composer.json +++ b/src/Symfony/Bridge/ProxyManager/composer.json @@ -35,7 +35,7 @@ "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } } } diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md index 2982e6b66f71a..905d242217c3f 100644 --- a/src/Symfony/Bridge/Twig/CHANGELOG.md +++ b/src/Symfony/Bridge/Twig/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +4.3.0 +----- + + * added the `form_parent()` function that allows to reliably retrieve the parent form in Twig templates + * added the `workflow_transition_blockers()` function + * deprecated the `$requestStack` and `$requestContext` arguments of the + `HttpFoundationExtension`, pass a `Symfony\Component\HttpFoundation\UrlHelper` + instance as the only argument instead + 4.2.0 ----- diff --git a/src/Symfony/Bridge/Twig/Command/DebugCommand.php b/src/Symfony/Bridge/Twig/Command/DebugCommand.php index 0c32779d1fac2..5533a2d98ffa2 100644 --- a/src/Symfony/Bridge/Twig/Command/DebugCommand.php +++ b/src/Symfony/Bridge/Twig/Command/DebugCommand.php @@ -20,6 +20,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Finder\Finder; +use Symfony\Component\HttpKernel\Debug\FileLinkFormatter; use Twig\Environment; use Twig\Loader\ChainLoader; use Twig\Loader\FilesystemLoader; @@ -39,8 +40,9 @@ class DebugCommand extends Command private $twigDefaultPath; private $rootDir; private $filesystemLoaders; + private $fileLinkFormatter; - public function __construct(Environment $twig, string $projectDir = null, array $bundlesMetadata = [], string $twigDefaultPath = null, string $rootDir = null) + public function __construct(Environment $twig, string $projectDir = null, array $bundlesMetadata = [], string $twigDefaultPath = null, string $rootDir = null, FileLinkFormatter $fileLinkFormatter = null) { parent::__construct(); @@ -49,6 +51,7 @@ public function __construct(Environment $twig, string $projectDir = null, array $this->bundlesMetadata = $bundlesMetadata; $this->twigDefaultPath = $twigDefaultPath; $this->rootDir = $rootDir; + $this->fileLinkFormatter = $fileLinkFormatter; } protected function configure() @@ -106,16 +109,28 @@ protected function execute(InputInterface $input, OutputInterface $output) private function displayPathsText(SymfonyStyle $io, string $name) { - $files = $this->findTemplateFiles($name); + $file = new \ArrayIterator($this->findTemplateFiles($name)); $paths = $this->getLoaderPaths($name); $io->section('Matched File'); - if ($files) { - $io->success(array_shift($files)); + if ($file->valid()) { + if ($fileLink = $this->getFileLink($file->key())) { + $io->block($file->current(), 'OK', sprintf('fg=black;bg=green;href=%s', $fileLink), ' ', true); + } else { + $io->success($file->current()); + } + $file->next(); - if ($files) { + if ($file->valid()) { $io->section('Overridden Files'); - $io->listing($files); + do { + if ($fileLink = $this->getFileLink($file->key())) { + $io->text(sprintf('* %s', $fileLink, $file->current())); + } else { + $io->text(sprintf('* %s', $file->current())); + } + $file->next(); + } while ($file->valid()); } } else { $alternatives = []; @@ -459,9 +474,9 @@ private function findTemplateFiles(string $name): array if (is_file($filename)) { if (false !== $realpath = realpath($filename)) { - $files[] = $this->getRelativePath($realpath); + $files[$realpath] = $this->getRelativePath($realpath); } else { - $files[] = $this->getRelativePath($filename); + $files[$filename] = $this->getRelativePath($filename); } } } @@ -569,4 +584,13 @@ private function getFilesystemLoaders(): array return $this->filesystemLoaders; } + + private function getFileLink(string $absolutePath): string + { + if (null === $this->fileLinkFormatter) { + return ''; + } + + return (string) $this->fileLinkFormatter->format($absolutePath, 1); + } } diff --git a/src/Symfony/Bridge/Twig/Extension/FormExtension.php b/src/Symfony/Bridge/Twig/Extension/FormExtension.php index f7429ad3bbb4c..909e20d58d690 100644 --- a/src/Symfony/Bridge/Twig/Extension/FormExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/FormExtension.php @@ -54,6 +54,7 @@ public function getFunctions() new TwigFunction('form_start', null, ['node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode', 'is_safe' => ['html']]), new TwigFunction('form_end', null, ['node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode', 'is_safe' => ['html']]), new TwigFunction('csrf_token', ['Symfony\Component\Form\FormRenderer', 'renderCsrfToken']), + new TwigFunction('form_parent', 'Symfony\Bridge\Twig\Extension\twig_get_form_parent'), ]; } @@ -115,3 +116,11 @@ function twig_is_root_form(FormView $formView) { return null === $formView->parent; } + +/** + * @internal + */ +function twig_get_form_parent(FormView $formView): ?FormView +{ + return $formView->parent; +} diff --git a/src/Symfony/Bridge/Twig/Extension/HttpFoundationExtension.php b/src/Symfony/Bridge/Twig/Extension/HttpFoundationExtension.php index 82b9a92f7516c..a72339e1243e1 100644 --- a/src/Symfony/Bridge/Twig/Extension/HttpFoundationExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/HttpFoundationExtension.php @@ -13,6 +13,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\UrlHelper; use Symfony\Component\Routing\RequestContext; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -24,13 +25,34 @@ */ class HttpFoundationExtension extends AbstractExtension { - private $requestStack; - private $requestContext; + private $urlHelper; - public function __construct(RequestStack $requestStack, RequestContext $requestContext = null) + /** + * @param UrlHelper $urlHelper + */ + public function __construct($urlHelper) { - $this->requestStack = $requestStack; - $this->requestContext = $requestContext; + if ($urlHelper instanceof UrlHelper) { + $this->urlHelper = $urlHelper; + + return; + } + + if (!$urlHelper instanceof RequestStack) { + throw new \TypeError(sprintf('The first argument must be an instance of "%s" or an instance of "%s".', UrlHelper::class, RequestStack::class)); + } + + @trigger_error(sprintf('Passing a "%s" instance as the first argument to the "%s" constructor is deprecated since Symfony 4.3, pass a "%s" instance instead.', RequestStack::class, __CLASS__, UrlHelper::class), E_USER_DEPRECATED); + + $requestContext = null; + if (2 === \func_num_args()) { + $requestContext = \func_get_arg(1); + if (null !== $requestContext && !$requestContext instanceof RequestContext) { + throw new \TypeError(sprintf('The second argument must be an instance of "%s".', RequestContext::class)); + } + } + + $this->urlHelper = new UrlHelper($urlHelper, $requestContext); } /** @@ -57,55 +79,7 @@ public function getFunctions() */ public function generateAbsoluteUrl($path) { - if (false !== strpos($path, '://') || '//' === substr($path, 0, 2)) { - return $path; - } - - if (!$request = $this->requestStack->getMasterRequest()) { - if (null !== $this->requestContext && '' !== $host = $this->requestContext->getHost()) { - $scheme = $this->requestContext->getScheme(); - $port = ''; - - if ('http' === $scheme && 80 != $this->requestContext->getHttpPort()) { - $port = ':'.$this->requestContext->getHttpPort(); - } elseif ('https' === $scheme && 443 != $this->requestContext->getHttpsPort()) { - $port = ':'.$this->requestContext->getHttpsPort(); - } - - if ('#' === $path[0]) { - $queryString = $this->requestContext->getQueryString(); - $path = $this->requestContext->getPathInfo().($queryString ? '?'.$queryString : '').$path; - } elseif ('?' === $path[0]) { - $path = $this->requestContext->getPathInfo().$path; - } - - if ('/' !== $path[0]) { - $path = rtrim($this->requestContext->getBaseUrl(), '/').'/'.$path; - } - - return $scheme.'://'.$host.$port.$path; - } - - return $path; - } - - if ('#' === $path[0]) { - $path = $request->getRequestUri().$path; - } elseif ('?' === $path[0]) { - $path = $request->getPathInfo().$path; - } - - if (!$path || '/' !== $path[0]) { - $prefix = $request->getPathInfo(); - $last = \strlen($prefix) - 1; - if ($last !== $pos = strrpos($prefix, '/')) { - $prefix = substr($prefix, 0, $pos).'/'; - } - - return $request->getUriForPath($prefix.$path); - } - - return $request->getSchemeAndHttpHost().$path; + return $this->urlHelper->getAbsoluteUrl($path); } /** @@ -121,15 +95,7 @@ public function generateAbsoluteUrl($path) */ public function generateRelativePath($path) { - if (false !== strpos($path, '://') || '//' === substr($path, 0, 2)) { - return $path; - } - - if (!$request = $this->requestStack->getMasterRequest()) { - return $path; - } - - return $request->getRelativeUriForPath($path); + return $this->urlHelper->getRelativePath($path); } /** diff --git a/src/Symfony/Bridge/Twig/Extension/WorkflowExtension.php b/src/Symfony/Bridge/Twig/Extension/WorkflowExtension.php index 05c236a1648da..85b4f7a4d73cd 100644 --- a/src/Symfony/Bridge/Twig/Extension/WorkflowExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/WorkflowExtension.php @@ -13,6 +13,7 @@ use Symfony\Component\Workflow\Registry; use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\TransitionBlockerList; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -38,6 +39,7 @@ public function getFunctions() new TwigFunction('workflow_has_marked_place', [$this, 'hasMarkedPlace']), new TwigFunction('workflow_marked_places', [$this, 'getMarkedPlaces']), new TwigFunction('workflow_metadata', [$this, 'getMetadata']), + new TwigFunction('workflow_transition_blockers', [$this, 'buildTransitionBlockerList']), ]; } @@ -120,6 +122,13 @@ public function getMetadata($subject, string $key, $metadataSubject = null, stri ; } + public function buildTransitionBlockerList($subject, string $transitionName, string $name = null): TransitionBlockerList + { + $workflow = $this->workflowRegistry->get($subject, $name); + + return $workflow->buildTransitionBlockerList($subject, $transitionName); + } + public function getName() { return 'workflow'; diff --git a/src/Symfony/Bridge/Twig/Mime/BodyRenderer.php b/src/Symfony/Bridge/Twig/Mime/BodyRenderer.php new file mode 100644 index 0000000000000..df2c9f91c3cf2 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Mime/BodyRenderer.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Mime; + +use League\HTMLToMarkdown\HtmlConverter; +use Symfony\Component\Mime\BodyRendererInterface; +use Symfony\Component\Mime\Message; +use Twig\Environment; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +final class BodyRenderer implements BodyRendererInterface +{ + private $twig; + private $context; + private $converter; + + public function __construct(Environment $twig, array $context = []) + { + $this->twig = $twig; + $this->context = $context; + if (class_exists(HtmlConverter::class)) { + $this->converter = new HtmlConverter([ + 'hard_break' => true, + 'strip_tags' => true, + 'remove_nodes' => 'head style', + ]); + } + } + + public function render(Message $message): void + { + if (!$message instanceof TemplatedEmail) { + return; + } + + $vars = array_merge($this->context, $message->getContext(), [ + 'email' => new WrappedTemplatedEmail($this->twig, $message), + ]); + + if ($template = $message->getTextTemplate()) { + $message->text($this->twig->render($template, $vars)); + } + + if ($template = $message->getHtmlTemplate()) { + $message->html($this->twig->render($template, $vars)); + } + + // if text body is empty, compute one from the HTML body + if (!$message->getTextBody() && null !== $html = $message->getHtmlBody()) { + $message->text($this->convertHtmlToText(\is_resource($html) ? stream_get_contents($html) : $html)); + } + } + + private function convertHtmlToText(string $html): string + { + if (null !== $this->converter) { + return $this->converter->convert($html); + } + + return strip_tags($html); + } +} diff --git a/src/Symfony/Bridge/Twig/Mime/TemplatedEmail.php b/src/Symfony/Bridge/Twig/Mime/TemplatedEmail.php new file mode 100644 index 0000000000000..e487055706892 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Mime/TemplatedEmail.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Mime; + +use Symfony\Component\Mime\Email; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class TemplatedEmail extends Email +{ + private $htmlTemplate; + private $textTemplate; + private $context = []; + + /** + * @return $this + */ + public function textTemplate(?string $template) + { + $this->textTemplate = $template; + + return $this; + } + + /** + * @return $this + */ + public function htmlTemplate(?string $template) + { + $this->htmlTemplate = $template; + + return $this; + } + + public function getTextTemplate(): ?string + { + return $this->textTemplate; + } + + public function getHtmlTemplate(): ?string + { + return $this->htmlTemplate; + } + + /** + * @return $this + */ + public function context(array $context) + { + $this->context = $context; + + return $this; + } + + public function getContext(): array + { + return $this->context; + } + + /** + * @internal + */ + public function __serialize(): array + { + return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize()]; + } + + /** + * @internal + */ + public function __unserialize(array $data): void + { + [$this->htmlTemplate, $this->textTemplate, $this->context, $parentData] = $data; + + parent::__unserialize($parentData); + } +} diff --git a/src/Symfony/Bridge/Twig/Mime/WrappedTemplatedEmail.php b/src/Symfony/Bridge/Twig/Mime/WrappedTemplatedEmail.php new file mode 100644 index 0000000000000..7c0b585a4eb63 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Mime/WrappedTemplatedEmail.php @@ -0,0 +1,199 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Mime; + +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\NamedAddress; +use Twig\Environment; + +/** + * @internal + * + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +final class WrappedTemplatedEmail +{ + private $twig; + private $message; + + public function __construct(Environment $twig, TemplatedEmail $message) + { + $this->twig = $twig; + $this->message = $message; + } + + public function toName(): string + { + $to = $this->message->getTo()[0]; + + return $to instanceof NamedAddress ? $to->getName() : ''; + } + + public function image(string $image, string $contentType = null): string + { + $file = $this->twig->getLoader()->getSourceContext($image); + if ($path = $file->getPath()) { + $this->message->embedFromPath($path, $image, $contentType); + } else { + $this->message->embed($file->getCode(), $image, $contentType); + } + + return 'cid:'.$image; + } + + public function attach(string $file, string $name = null, string $contentType = null): void + { + $file = $this->twig->getLoader()->getSourceContext($file); + if ($path = $file->getPath()) { + $this->message->attachFromPath($path, $name, $contentType); + } else { + $this->message->attach($file->getCode(), $name, $contentType); + } + } + + /** + * @return $this + */ + public function setSubject(string $subject) + { + $this->message->subject($subject); + + return $this; + } + + public function getSubject(): ?string + { + return $this->message->getSubject(); + } + + /** + * @return $this + */ + public function setReturnPath(string $address) + { + $this->message->returnPath($address); + + return $this; + } + + public function getReturnPath(): string + { + return $this->message->getReturnPath(); + } + + /** + * @return $this + */ + public function addFrom(string $address, string $name = null) + { + $this->message->addFrom($name ? new NamedAddress($address, $name) : new Address($address)); + + return $this; + } + + /** + * @return (Address|NamedAddress)[] + */ + public function getFrom(): array + { + return $this->message->getFrom(); + } + + /** + * @return $this + */ + public function addReplyTo(string $address) + { + $this->message->addReplyTo($address); + + return $this; + } + + /** + * @return Address[] + */ + public function getReplyTo(): array + { + return $this->message->getReplyTo(); + } + + /** + * @return $this + */ + public function addTo(string $address, string $name = null) + { + $this->message->addTo($name ? new NamedAddress($address, $name) : new Address($address)); + + return $this; + } + + /** + * @return (Address|NamedAddress)[] + */ + public function getTo(): array + { + return $this->message->getTo(); + } + + /** + * @return $this + */ + public function addCc(string $address, string $name = null) + { + $this->message->addCc($name ? new NamedAddress($address, $name) : new Address($address)); + + return $this; + } + + /** + * @return (Address|NamedAddress)[] + */ + public function getCc(): array + { + return $this->message->getCc(); + } + + /** + * @return $this + */ + public function addBcc(string $address, string $name = null) + { + $this->message->addBcc($name ? new NamedAddress($address, $name) : new Address($address)); + + return $this; + } + + /** + * @return (Address|NamedAddress)[] + */ + public function getBcc(): array + { + return $this->message->getBcc(); + } + + /** + * @return $this + */ + public function setPriority(int $priority) + { + $this->message->setPriority($priority); + + return $this; + } + + public function getPriority(): int + { + return $this->message->getPriority(); + } +} diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig index 11e8f3f70b590..03335315e9380 100644 --- a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig +++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig @@ -179,9 +179,17 @@ {%- set help_attr = help_attr|merge({class: (help_attr.class|default('') ~ ' help-block')|trim}) -%} {%- if translation_domain is same as(false) -%} - {{- help -}} + {%- if help_html is same as(false) -%} + {{- help -}} + {%- else -%} + {{- help|raw -}} + {%- endif -%} {%- else -%} - {{- help|trans({}, translation_domain) -}} + {%- if help_html is same as(false) -%} + {{- help|trans(help_translation_parameters, translation_domain) -}} + {%- else -%} + {{- help|trans(help_translation_parameters, translation_domain)|raw -}} + {%- endif -%} {%- endif -%} {%- endif -%} diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig index acca01bc432a2..1848d0dc9838c 100644 --- a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig +++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig @@ -106,19 +106,24 @@ {%- endblock dateinterval_widget %} {% block percent_widget -%} -
- {{- block('form_widget_simple') -}} -
- % + {%- if symbol -%} +
+ {{- block('form_widget_simple') -}} +
+ {{ symbol|default('%') }} +
-
+ {%- else -%} + {{- block('form_widget_simple') -}} + {%- endif -%} {%- endblock percent_widget %} {% block file_widget -%} <{{ element|default('div') }} class="custom-file"> {%- set type = type|default('file') -%} {{- block('form_widget_simple') -}} -